meta-ads-mcp 1.0.15__tar.gz → 1.0.16__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.

Potentially problematic release.


This version of meta-ads-mcp might be problematic. Click here for more details.

Files changed (84) hide show
  1. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/PKG-INFO +37 -1
  2. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/README.md +36 -0
  3. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/STREAMABLE_HTTP_SETUP.md +10 -0
  4. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/meta_ads_mcp/__init__.py +1 -1
  5. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/meta_ads_mcp/core/campaigns.py +35 -1
  6. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/pyproject.toml +1 -1
  7. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/server.json +2 -2
  8. meta_ads_mcp-1.0.16/tests/test_campaign_objective_filter.py +518 -0
  9. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/.github/workflows/publish-mcp.yml +0 -0
  10. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/.github/workflows/publish.yml +0 -0
  11. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/.github/workflows/test.yml +0 -0
  12. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/.gitignore +0 -0
  13. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/CUSTOM_META_APP.md +0 -0
  14. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/Dockerfile +0 -0
  15. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/LICENSE +0 -0
  16. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/LOCAL_INSTALLATION.md +0 -0
  17. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/META_API_NOTES.md +0 -0
  18. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/RELEASE.md +0 -0
  19. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/examples/README.md +0 -0
  20. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/examples/example_http_client.py +0 -0
  21. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/future_improvements.md +0 -0
  22. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/images/meta-ads-example.png +0 -0
  23. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/meta_ads_auth.sh +0 -0
  24. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/meta_ads_mcp/__main__.py +0 -0
  25. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/meta_ads_mcp/core/__init__.py +0 -0
  26. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/meta_ads_mcp/core/accounts.py +0 -0
  27. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/meta_ads_mcp/core/ads.py +0 -0
  28. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/meta_ads_mcp/core/ads_library.py +0 -0
  29. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/meta_ads_mcp/core/adsets.py +0 -0
  30. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/meta_ads_mcp/core/api.py +0 -0
  31. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/meta_ads_mcp/core/auth.py +0 -0
  32. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/meta_ads_mcp/core/authentication.py +0 -0
  33. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/meta_ads_mcp/core/budget_schedules.py +0 -0
  34. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/meta_ads_mcp/core/callback_server.py +0 -0
  35. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/meta_ads_mcp/core/duplication.py +0 -0
  36. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/meta_ads_mcp/core/http_auth_integration.py +0 -0
  37. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/meta_ads_mcp/core/insights.py +0 -0
  38. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/meta_ads_mcp/core/openai_deep_research.py +0 -0
  39. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/meta_ads_mcp/core/pipeboard_auth.py +0 -0
  40. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/meta_ads_mcp/core/reports.py +0 -0
  41. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/meta_ads_mcp/core/resources.py +0 -0
  42. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/meta_ads_mcp/core/server.py +0 -0
  43. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/meta_ads_mcp/core/targeting.py +0 -0
  44. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/meta_ads_mcp/core/utils.py +0 -0
  45. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/requirements.txt +0 -0
  46. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/setup.py +0 -0
  47. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/smithery.yaml +0 -0
  48. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/tests/README.md +0 -0
  49. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/tests/README_REGRESSION_TESTS.md +0 -0
  50. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/tests/__init__.py +0 -0
  51. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/tests/conftest.py +0 -0
  52. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/tests/e2e_account_info_search_issue.py +0 -0
  53. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/tests/test_account_info_access_fix.py +0 -0
  54. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/tests/test_account_search.py +0 -0
  55. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/tests/test_budget_update.py +0 -0
  56. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/tests/test_budget_update_e2e.py +0 -0
  57. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/tests/test_create_ad_creative_simple.py +0 -0
  58. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/tests/test_create_simple_creative_e2e.py +0 -0
  59. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/tests/test_dsa_beneficiary.py +0 -0
  60. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/tests/test_dsa_integration.py +0 -0
  61. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/tests/test_duplication.py +0 -0
  62. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/tests/test_duplication_regression.py +0 -0
  63. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/tests/test_dynamic_creatives.py +0 -0
  64. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/tests/test_estimate_audience_size.py +0 -0
  65. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/tests/test_estimate_audience_size_e2e.py +0 -0
  66. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/tests/test_get_account_pages.py +0 -0
  67. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/tests/test_get_ad_creatives_fix.py +0 -0
  68. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/tests/test_get_ad_image_quality_improvements.py +0 -0
  69. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/tests/test_get_ad_image_regression.py +0 -0
  70. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/tests/test_http_transport.py +0 -0
  71. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/tests/test_insights_actions_and_values_e2e.py +0 -0
  72. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/tests/test_insights_pagination.py +0 -0
  73. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/tests/test_integration_openai_mcp.py +0 -0
  74. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/tests/test_is_dynamic_creative_adset.py +0 -0
  75. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/tests/test_mobile_app_adset_creation.py +0 -0
  76. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/tests/test_mobile_app_adset_issue.py +0 -0
  77. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/tests/test_openai.py +0 -0
  78. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/tests/test_openai_mcp_deep_research.py +0 -0
  79. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/tests/test_page_discovery.py +0 -0
  80. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/tests/test_page_discovery_integration.py +0 -0
  81. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/tests/test_targeting.py +0 -0
  82. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/tests/test_targeting_search_e2e.py +0 -0
  83. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/tests/test_update_ad_creative_id.py +0 -0
  84. {meta_ads_mcp-1.0.15 → meta_ads_mcp-1.0.16}/tests/test_upload_ad_image.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meta-ads-mcp
3
- Version: 1.0.15
3
+ Version: 1.0.16
4
4
  Summary: Model Context Protocol (MCP) server 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
@@ -68,6 +68,16 @@ The fastest and most reliable way to get started is to **[🚀 Get started with
68
68
 
69
69
  That's it! You can now ask Claude to analyze your Meta ad campaigns, get performance insights, and manage your advertising.
70
70
 
71
+ #### Advanced: Direct Token Authentication (Claude)
72
+
73
+ For direct token-based authentication without the interactive flow, use this URL format when adding the integration:
74
+
75
+ ```
76
+ https://mcp.pipeboard.co/meta-ads-mcp?token=YOUR_PIPEBOARD_TOKEN
77
+ ```
78
+
79
+ Get your token at [pipeboard.co/api-tokens](https://pipeboard.co/api-tokens).
80
+
71
81
  ### For Cursor Users
72
82
 
73
83
  Add the following to your `~/.cursor/mcp.json`. Once you enable the remote MCP, click on "Needs login" to finish the login process.
@@ -83,12 +93,38 @@ Add the following to your `~/.cursor/mcp.json`. Once you enable the remote MCP,
83
93
  }
84
94
  ```
85
95
 
96
+ #### Advanced: Direct Token Authentication (Cursor)
97
+
98
+ If you prefer to authenticate without the interactive login flow, you can include your Pipeboard API token directly in the URL:
99
+
100
+ ```json
101
+ {
102
+ "mcpServers": {
103
+ "meta-ads-remote": {
104
+ "url": "https://mcp.pipeboard.co/meta-ads-mcp?token=YOUR_PIPEBOARD_TOKEN"
105
+ }
106
+ }
107
+ }
108
+ ```
109
+
110
+ Get your token at [pipeboard.co/api-tokens](https://pipeboard.co/api-tokens).
111
+
86
112
  ### For Other MCP Clients
87
113
 
88
114
  Use the Remote MCP URL: `https://mcp.pipeboard.co/meta-ads-mcp`
89
115
 
90
116
  **[📖 Get detailed setup instructions for your AI client here](https://pipeboard.co)**
91
117
 
118
+ #### Advanced: Direct Token Authentication (Other Clients)
119
+
120
+ For MCP clients that support token-based authentication, you can append your Pipeboard API token to the URL:
121
+
122
+ ```
123
+ https://mcp.pipeboard.co/meta-ads-mcp?token=YOUR_PIPEBOARD_TOKEN
124
+ ```
125
+
126
+ This bypasses the interactive login flow and authenticates immediately. Get your token at [pipeboard.co/api-tokens](https://pipeboard.co/api-tokens).
127
+
92
128
  ## Local Installation (Technical Users Only)
93
129
 
94
130
  If you're a developer or need to customize the installation, you can run Meta Ads MCP locally. **Most marketers should use the Remote MCP above instead!** For complete technical setup instructions, see our **[Local Installation Guide](LOCAL_INSTALLATION.md)**.
@@ -43,6 +43,16 @@ The fastest and most reliable way to get started is to **[🚀 Get started with
43
43
 
44
44
  That's it! You can now ask Claude to analyze your Meta ad campaigns, get performance insights, and manage your advertising.
45
45
 
46
+ #### Advanced: Direct Token Authentication (Claude)
47
+
48
+ For direct token-based authentication without the interactive flow, use this URL format when adding the integration:
49
+
50
+ ```
51
+ https://mcp.pipeboard.co/meta-ads-mcp?token=YOUR_PIPEBOARD_TOKEN
52
+ ```
53
+
54
+ Get your token at [pipeboard.co/api-tokens](https://pipeboard.co/api-tokens).
55
+
46
56
  ### For Cursor Users
47
57
 
48
58
  Add the following to your `~/.cursor/mcp.json`. Once you enable the remote MCP, click on "Needs login" to finish the login process.
@@ -58,12 +68,38 @@ Add the following to your `~/.cursor/mcp.json`. Once you enable the remote MCP,
58
68
  }
59
69
  ```
60
70
 
71
+ #### Advanced: Direct Token Authentication (Cursor)
72
+
73
+ If you prefer to authenticate without the interactive login flow, you can include your Pipeboard API token directly in the URL:
74
+
75
+ ```json
76
+ {
77
+ "mcpServers": {
78
+ "meta-ads-remote": {
79
+ "url": "https://mcp.pipeboard.co/meta-ads-mcp?token=YOUR_PIPEBOARD_TOKEN"
80
+ }
81
+ }
82
+ }
83
+ ```
84
+
85
+ Get your token at [pipeboard.co/api-tokens](https://pipeboard.co/api-tokens).
86
+
61
87
  ### For Other MCP Clients
62
88
 
63
89
  Use the Remote MCP URL: `https://mcp.pipeboard.co/meta-ads-mcp`
64
90
 
65
91
  **[📖 Get detailed setup instructions for your AI client here](https://pipeboard.co)**
66
92
 
93
+ #### Advanced: Direct Token Authentication (Other Clients)
94
+
95
+ For MCP clients that support token-based authentication, you can append your Pipeboard API token to the URL:
96
+
97
+ ```
98
+ https://mcp.pipeboard.co/meta-ads-mcp?token=YOUR_PIPEBOARD_TOKEN
99
+ ```
100
+
101
+ This bypasses the interactive login flow and authenticates immediately. Get your token at [pipeboard.co/api-tokens](https://pipeboard.co/api-tokens).
102
+
67
103
  ## Local Installation (Technical Users Only)
68
104
 
69
105
  If you're a developer or need to customize the installation, you can run Meta Ads MCP locally. **Most marketers should use the Remote MCP above instead!** For complete technical setup instructions, see our **[Local Installation Guide](LOCAL_INSTALLATION.md)**.
@@ -83,6 +83,16 @@ curl -H "Authorization: Bearer your_pipeboard_token" \
83
83
  -d '{"jsonrpc":"2.0","method":"tools/list","id":1}'
84
84
  ```
85
85
 
86
+ #### Remote MCP: Token in URL
87
+
88
+ When using the hosted Remote MCP at `https://mcp.pipeboard.co/meta-ads-mcp`, you can alternatively authenticate by including the token as a URL parameter:
89
+
90
+ ```
91
+ https://mcp.pipeboard.co/meta-ads-mcp?token=YOUR_PIPEBOARD_TOKEN
92
+ ```
93
+
94
+ This is particularly useful for MCP clients that don't support interactive authentication flows.
95
+
86
96
  ### Alternative Method: Direct Meta Token
87
97
 
88
98
  If you have a Meta Developer App, you can use a direct access token via the `X-META-ACCESS-TOKEN` header. This is less common.
@@ -6,7 +6,7 @@ This package provides a Meta Ads MCP integration
6
6
 
7
7
  from meta_ads_mcp.core.server import main
8
8
 
9
- __version__ = "1.0.15"
9
+ __version__ = "1.0.16"
10
10
 
11
11
  __all__ = [
12
12
  'get_ad_accounts',
@@ -9,7 +9,14 @@ from .server import mcp_server
9
9
 
10
10
  @mcp_server.tool()
11
11
  @meta_api_tool
12
- async def get_campaigns(account_id: str, access_token: Optional[str] = None, limit: int = 10, status_filter: str = "", after: str = "") -> str:
12
+ async def get_campaigns(
13
+ account_id: str,
14
+ access_token: Optional[str] = None,
15
+ limit: int = 10,
16
+ status_filter: str = "",
17
+ objective_filter: Union[str, List[str]] = "",
18
+ after: str = ""
19
+ ) -> str:
13
20
  """
14
21
  Get campaigns for a Meta Ads account with optional filtering.
15
22
 
@@ -26,6 +33,11 @@ async def get_campaigns(account_id: str, access_token: Optional[str] = None, lim
26
33
  status_filter: Filter by effective status (e.g., 'ACTIVE', 'PAUSED', 'ARCHIVED').
27
34
  Maps to the 'effective_status' API parameter, which expects an array
28
35
  (this function handles the required JSON formatting). Leave empty for all statuses.
36
+ objective_filter: Filter by campaign objective(s). Can be a single objective string or a list of objectives.
37
+ Valid objectives: 'OUTCOME_AWARENESS', 'OUTCOME_TRAFFIC', 'OUTCOME_ENGAGEMENT',
38
+ 'OUTCOME_LEADS', 'OUTCOME_SALES', 'OUTCOME_APP_PROMOTION'.
39
+ Examples: 'OUTCOME_LEADS' or ['OUTCOME_LEADS', 'OUTCOME_SALES'].
40
+ Leave empty for all objectives.
29
41
  after: Pagination cursor to get the next set of results
30
42
  """
31
43
  # Require explicit account_id
@@ -38,10 +50,32 @@ async def get_campaigns(account_id: str, access_token: Optional[str] = None, lim
38
50
  "limit": limit
39
51
  }
40
52
 
53
+ # Build filtering array for complex filtering
54
+ filters = []
55
+
41
56
  if status_filter:
42
57
  # API expects an array, encode it as a JSON string
43
58
  params["effective_status"] = json.dumps([status_filter])
44
59
 
60
+ # Handle objective filtering - supports both single string and list of objectives
61
+ if objective_filter:
62
+ # Convert single string to list for consistent handling
63
+ objectives = [objective_filter] if isinstance(objective_filter, str) else objective_filter
64
+
65
+ # Filter out empty strings
66
+ objectives = [obj for obj in objectives if obj]
67
+
68
+ if objectives:
69
+ filters.append({
70
+ "field": "objective",
71
+ "operator": "IN",
72
+ "value": objectives
73
+ })
74
+
75
+ # Add filtering parameter if we have filters
76
+ if filters:
77
+ params["filtering"] = json.dumps(filters)
78
+
45
79
  if after:
46
80
  params["after"] = after
47
81
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "meta-ads-mcp"
7
- version = "1.0.15"
7
+ version = "1.0.16"
8
8
  description = "Model Context Protocol (MCP) server for interacting with Meta Ads API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-16/server.schema.json",
3
3
  "name": "co.pipeboard/meta-ads-mcp",
4
4
  "description": "Facebook / Meta Ads automation with AI: analyze performance, test creatives, optimize spend.",
5
- "version": "1.0.15",
5
+ "version": "1.0.16",
6
6
  "remotes": [
7
7
  {
8
8
  "type": "streamable-http",
@@ -13,7 +13,7 @@
13
13
  {
14
14
  "registryType": "pypi",
15
15
  "identifier": "meta-ads-mcp",
16
- "version": "1.0.15",
16
+ "version": "1.0.16",
17
17
  "transport": {
18
18
  "type": "stdio"
19
19
  }
@@ -0,0 +1,518 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Campaign Objective Filtering Tests for Meta Ads MCP
4
+
5
+ This test validates that the get_campaigns function correctly filters
6
+ campaigns by objective type, supporting both single and multiple objectives.
7
+
8
+ Test scenarios:
9
+ 1. Single objective filtering (e.g., OUTCOME_LEADS)
10
+ 2. Multiple objective filtering (e.g., [OUTCOME_LEADS, OUTCOME_SALES])
11
+ 3. Combined status and objective filtering
12
+ 4. Empty/no filtering (returns all campaigns)
13
+ 5. Edge cases (empty strings, invalid types)
14
+ """
15
+
16
+ import pytest
17
+ import requests
18
+ import json
19
+ from typing import Dict, Any, List
20
+
21
+
22
+ class CampaignObjectiveFilterTester:
23
+ """Test suite for campaign objective filtering functionality"""
24
+
25
+ def __init__(self, base_url: str = "http://localhost:8080"):
26
+ self.base_url = base_url.rstrip('/')
27
+ self.endpoint = f"{self.base_url}/mcp/"
28
+ self.request_id = 1
29
+
30
+ def _make_request(self, method: str, params: Dict[str, Any] = None) -> Dict[str, Any]:
31
+ """Make a JSON-RPC request to the MCP server"""
32
+
33
+ headers = {
34
+ "Content-Type": "application/json",
35
+ "Accept": "application/json, text/event-stream",
36
+ "User-Agent": "Campaign-Filter-Test-Client/1.0"
37
+ }
38
+
39
+ payload = {
40
+ "jsonrpc": "2.0",
41
+ "method": method,
42
+ "id": self.request_id
43
+ }
44
+
45
+ if params:
46
+ payload["params"] = params
47
+
48
+ try:
49
+ response = requests.post(
50
+ self.endpoint,
51
+ headers=headers,
52
+ json=payload,
53
+ timeout=30
54
+ )
55
+
56
+ self.request_id += 1
57
+
58
+ result = {
59
+ "status_code": response.status_code,
60
+ "headers": dict(response.headers),
61
+ "json": response.json() if response.status_code == 200 else None,
62
+ "text": response.text,
63
+ "success": response.status_code == 200
64
+ }
65
+
66
+ # Parse the content if successful
67
+ if result["success"] and result["json"]:
68
+ response_data = result["json"].get("result", {})
69
+ content = response_data.get("content", [{}])[0].get("text", "")
70
+ try:
71
+ result["parsed_content"] = json.loads(content)
72
+ except json.JSONDecodeError:
73
+ result["parsed_content"] = None
74
+
75
+ return result
76
+
77
+ except requests.exceptions.RequestException as e:
78
+ return {
79
+ "status_code": 0,
80
+ "headers": {},
81
+ "json": None,
82
+ "text": str(e),
83
+ "success": False,
84
+ "error": str(e)
85
+ }
86
+
87
+ def get_campaigns(self, account_id: str, **filters) -> Dict[str, Any]:
88
+ """Get campaigns with optional filters"""
89
+
90
+ arguments = {"account_id": account_id}
91
+ arguments.update(filters)
92
+
93
+ return self._make_request("tools/call", {
94
+ "name": "get_campaigns",
95
+ "arguments": arguments
96
+ })
97
+
98
+ def test_no_filtering(self, account_id: str) -> Dict[str, Any]:
99
+ """Test getting campaigns without any filtering"""
100
+
101
+ print(f"\n🔍 Test 1: Get campaigns without filtering")
102
+
103
+ result = self.get_campaigns(account_id)
104
+
105
+ if not result["success"]:
106
+ return {
107
+ "test": "no_filtering",
108
+ "success": False,
109
+ "error": result.get("text", "Unknown error")
110
+ }
111
+
112
+ campaigns = result.get("parsed_content", {}).get("data", [])
113
+
114
+ return {
115
+ "test": "no_filtering",
116
+ "success": True,
117
+ "campaign_count": len(campaigns),
118
+ "campaigns": campaigns,
119
+ "objectives": [c.get("objective") for c in campaigns if "objective" in c]
120
+ }
121
+
122
+ def test_single_objective_filter(self, account_id: str, objective: str) -> Dict[str, Any]:
123
+ """Test filtering by a single objective"""
124
+
125
+ print(f"\n🔍 Test 2: Filter by single objective: {objective}")
126
+
127
+ result = self.get_campaigns(account_id, objective_filter=objective)
128
+
129
+ if not result["success"]:
130
+ return {
131
+ "test": "single_objective",
132
+ "objective": objective,
133
+ "success": False,
134
+ "error": result.get("text", "Unknown error")
135
+ }
136
+
137
+ campaigns = result.get("parsed_content", {}).get("data", [])
138
+ objectives_found = [c.get("objective") for c in campaigns if "objective" in c]
139
+
140
+ # Verify all campaigns match the filter
141
+ all_match = all(obj == objective for obj in objectives_found) if objectives_found else True
142
+
143
+ return {
144
+ "test": "single_objective",
145
+ "objective": objective,
146
+ "success": True,
147
+ "all_match_filter": all_match,
148
+ "campaign_count": len(campaigns),
149
+ "objectives": objectives_found
150
+ }
151
+
152
+ def test_multiple_objectives_filter(self, account_id: str, objectives: List[str]) -> Dict[str, Any]:
153
+ """Test filtering by multiple objectives"""
154
+
155
+ print(f"\n🔍 Test 3: Filter by multiple objectives: {objectives}")
156
+
157
+ result = self.get_campaigns(account_id, objective_filter=objectives)
158
+
159
+ if not result["success"]:
160
+ return {
161
+ "test": "multiple_objectives",
162
+ "objectives": objectives,
163
+ "success": False,
164
+ "error": result.get("text", "Unknown error")
165
+ }
166
+
167
+ campaigns = result.get("parsed_content", {}).get("data", [])
168
+ objectives_found = [c.get("objective") for c in campaigns if "objective" in c]
169
+
170
+ # Verify all campaigns match one of the filter objectives
171
+ all_match = all(obj in objectives for obj in objectives_found) if objectives_found else True
172
+
173
+ return {
174
+ "test": "multiple_objectives",
175
+ "objectives": objectives,
176
+ "success": True,
177
+ "all_match_filter": all_match,
178
+ "campaign_count": len(campaigns),
179
+ "objectives_found": objectives_found,
180
+ "unique_objectives": list(set(objectives_found))
181
+ }
182
+
183
+ def test_combined_status_and_objective_filter(
184
+ self,
185
+ account_id: str,
186
+ status: str,
187
+ objective: str
188
+ ) -> Dict[str, Any]:
189
+ """Test filtering by both status and objective"""
190
+
191
+ print(f"\n🔍 Test 4: Filter by status '{status}' and objective '{objective}'")
192
+
193
+ result = self.get_campaigns(
194
+ account_id,
195
+ status_filter=status,
196
+ objective_filter=objective
197
+ )
198
+
199
+ if not result["success"]:
200
+ return {
201
+ "test": "combined_filters",
202
+ "status": status,
203
+ "objective": objective,
204
+ "success": False,
205
+ "error": result.get("text", "Unknown error")
206
+ }
207
+
208
+ campaigns = result.get("parsed_content", {}).get("data", [])
209
+
210
+ # Check if campaigns match both filters
211
+ objectives_match = all(
212
+ c.get("objective") == objective
213
+ for c in campaigns if "objective" in c
214
+ )
215
+
216
+ return {
217
+ "test": "combined_filters",
218
+ "status": status,
219
+ "objective": objective,
220
+ "success": True,
221
+ "objectives_match": objectives_match,
222
+ "campaign_count": len(campaigns),
223
+ "campaigns": [
224
+ {
225
+ "id": c.get("id"),
226
+ "name": c.get("name"),
227
+ "status": c.get("status"),
228
+ "objective": c.get("objective")
229
+ }
230
+ for c in campaigns
231
+ ]
232
+ }
233
+
234
+ def test_empty_string_filter(self, account_id: str) -> Dict[str, Any]:
235
+ """Test that empty string filter returns all campaigns"""
236
+
237
+ print(f"\n🔍 Test 5: Empty string filter (should return all)")
238
+
239
+ result = self.get_campaigns(account_id, objective_filter="")
240
+
241
+ if not result["success"]:
242
+ return {
243
+ "test": "empty_string_filter",
244
+ "success": False,
245
+ "error": result.get("text", "Unknown error")
246
+ }
247
+
248
+ campaigns = result.get("parsed_content", {}).get("data", [])
249
+
250
+ return {
251
+ "test": "empty_string_filter",
252
+ "success": True,
253
+ "campaign_count": len(campaigns),
254
+ "note": "Empty filter should return all campaigns"
255
+ }
256
+
257
+ def test_empty_list_filter(self, account_id: str) -> Dict[str, Any]:
258
+ """Test that empty list filter returns all campaigns"""
259
+
260
+ print(f"\n🔍 Test 6: Empty list filter (should return all)")
261
+
262
+ result = self.get_campaigns(account_id, objective_filter=[])
263
+
264
+ if not result["success"]:
265
+ return {
266
+ "test": "empty_list_filter",
267
+ "success": False,
268
+ "error": result.get("text", "Unknown error")
269
+ }
270
+
271
+ campaigns = result.get("parsed_content", {}).get("data", [])
272
+
273
+ return {
274
+ "test": "empty_list_filter",
275
+ "success": True,
276
+ "campaign_count": len(campaigns),
277
+ "note": "Empty list should return all campaigns"
278
+ }
279
+
280
+ def run_all_tests(self, account_id: str) -> bool:
281
+ """Run comprehensive campaign objective filtering tests"""
282
+
283
+ print("🚀 Campaign Objective Filtering Test Suite")
284
+ print("="*60)
285
+
286
+ # Check server availability
287
+ try:
288
+ response = requests.get(f"{self.base_url}/", timeout=5)
289
+ server_running = response.status_code in [200, 404]
290
+ except:
291
+ server_running = False
292
+
293
+ if not server_running:
294
+ print("❌ Server is not running at", self.base_url)
295
+ print(" Please start the server with:")
296
+ print(" python3 -m meta_ads_mcp --transport streamable-http --port 8080")
297
+ return False
298
+
299
+ print("✅ Server is running")
300
+ print(f"🏢 Testing with account: {account_id}")
301
+
302
+ test_results = []
303
+
304
+ # Test 1: No filtering (get all campaigns to see what objectives exist)
305
+ result1 = self.test_no_filtering(account_id)
306
+ test_results.append(result1)
307
+ if result1["success"]:
308
+ print(f"✅ Found {result1['campaign_count']} campaigns")
309
+ print(f" Objectives: {set(result1.get('objectives', []))}")
310
+ available_objectives = list(set(result1.get('objectives', [])))
311
+ else:
312
+ print(f"❌ Failed: {result1.get('error')}")
313
+ return False
314
+
315
+ # If we have campaigns with objectives, test filtering
316
+ if available_objectives:
317
+ # Test 2: Single objective filter
318
+ test_objective = available_objectives[0]
319
+ result2 = self.test_single_objective_filter(account_id, test_objective)
320
+ test_results.append(result2)
321
+ if result2["success"]:
322
+ if result2["all_match_filter"]:
323
+ print(f"✅ Single objective filter works correctly")
324
+ print(f" Found {result2['campaign_count']} campaigns with objective '{test_objective}'")
325
+ else:
326
+ print(f"⚠️ Filter returned campaigns with wrong objectives")
327
+ print(f" Expected: {test_objective}")
328
+ print(f" Found: {set(result2['objectives'])}")
329
+ else:
330
+ print(f"❌ Single objective filter failed: {result2.get('error')}")
331
+
332
+ # Test 3: Multiple objectives filter (if we have at least 2 objectives)
333
+ if len(available_objectives) >= 2:
334
+ test_objectives = available_objectives[:2]
335
+ result3 = self.test_multiple_objectives_filter(account_id, test_objectives)
336
+ test_results.append(result3)
337
+ if result3["success"]:
338
+ if result3["all_match_filter"]:
339
+ print(f"✅ Multiple objectives filter works correctly")
340
+ print(f" Found {result3['campaign_count']} campaigns")
341
+ print(f" Unique objectives: {result3['unique_objectives']}")
342
+ else:
343
+ print(f"⚠️ Filter returned campaigns with wrong objectives")
344
+ print(f" Expected: {test_objectives}")
345
+ print(f" Found: {result3['unique_objectives']}")
346
+ else:
347
+ print(f"❌ Multiple objectives filter failed: {result3.get('error')}")
348
+ else:
349
+ print(f"ℹ️ Skipping multiple objectives test (only {len(available_objectives)} objective found)")
350
+
351
+ # Test 4: Combined status and objective filter
352
+ result4 = self.test_combined_status_and_objective_filter(
353
+ account_id,
354
+ "ACTIVE",
355
+ test_objective
356
+ )
357
+ test_results.append(result4)
358
+ if result4["success"]:
359
+ if result4["objectives_match"]:
360
+ print(f"✅ Combined filters work correctly")
361
+ print(f" Found {result4['campaign_count']} ACTIVE campaigns with objective '{test_objective}'")
362
+ else:
363
+ print(f"⚠️ Combined filter returned campaigns with wrong objectives")
364
+ else:
365
+ print(f"❌ Combined filter failed: {result4.get('error')}")
366
+ else:
367
+ print("ℹ️ No campaigns found, skipping filter tests")
368
+
369
+ # Test 5: Empty string filter
370
+ result5 = self.test_empty_string_filter(account_id)
371
+ test_results.append(result5)
372
+ if result5["success"]:
373
+ print(f"✅ Empty string filter works correctly")
374
+ print(f" Returned {result5['campaign_count']} campaigns (same as no filter)")
375
+ else:
376
+ print(f"❌ Empty string filter failed: {result5.get('error')}")
377
+
378
+ # Test 6: Empty list filter
379
+ result6 = self.test_empty_list_filter(account_id)
380
+ test_results.append(result6)
381
+ if result6["success"]:
382
+ print(f"✅ Empty list filter works correctly")
383
+ print(f" Returned {result6['campaign_count']} campaigns (same as no filter)")
384
+ else:
385
+ print(f"❌ Empty list filter failed: {result6.get('error')}")
386
+
387
+ # Final assessment
388
+ print("\n" + "="*60)
389
+ print("📊 FINAL RESULTS")
390
+ print("="*60)
391
+
392
+ successful_tests = sum(1 for r in test_results if r.get("success", False))
393
+ total_tests = len(test_results)
394
+
395
+ if successful_tests == total_tests:
396
+ print(f"✅ All {total_tests} tests passed!")
397
+ return True
398
+ else:
399
+ print(f"⚠️ {successful_tests}/{total_tests} tests passed")
400
+ failed_tests = [r for r in test_results if not r.get("success", False)]
401
+ print(f" Failed tests: {[r.get('test') for r in failed_tests]}")
402
+ return False
403
+
404
+
405
+ # Pytest-compatible test functions
406
+ @pytest.fixture
407
+ def tester(server_url):
408
+ """Create a tester instance"""
409
+ return CampaignObjectiveFilterTester(server_url)
410
+
411
+
412
+ @pytest.fixture
413
+ def account_id():
414
+ """Default test account ID"""
415
+ return "act_701351919139047"
416
+
417
+
418
+ def test_server_running(check_server_running):
419
+ """Verify the server is running before tests"""
420
+ assert check_server_running
421
+
422
+
423
+ def test_no_filtering(tester, account_id, check_server_running):
424
+ """Test getting campaigns without filtering"""
425
+ result = tester.test_no_filtering(account_id)
426
+ assert result["success"], f"Failed: {result.get('error')}"
427
+ assert isinstance(result["campaign_count"], int)
428
+ print(f"Found {result['campaign_count']} campaigns")
429
+
430
+
431
+ def test_single_objective_filter(tester, account_id, check_server_running):
432
+ """Test filtering by a single objective"""
433
+ # First get available objectives
434
+ no_filter_result = tester.test_no_filtering(account_id)
435
+ assert no_filter_result["success"]
436
+
437
+ objectives = no_filter_result.get("objectives", [])
438
+ if not objectives:
439
+ pytest.skip("No campaigns with objectives found")
440
+
441
+ test_objective = objectives[0]
442
+ result = tester.test_single_objective_filter(account_id, test_objective)
443
+ assert result["success"], f"Failed: {result.get('error')}"
444
+ assert result["all_match_filter"], "Filter returned campaigns with wrong objectives"
445
+ print(f"Single objective filter: {result['campaign_count']} campaigns with {test_objective}")
446
+
447
+
448
+ def test_multiple_objectives_filter(tester, account_id, check_server_running):
449
+ """Test filtering by multiple objectives"""
450
+ # First get available objectives
451
+ no_filter_result = tester.test_no_filtering(account_id)
452
+ assert no_filter_result["success"]
453
+
454
+ available_objectives = list(set(no_filter_result.get("objectives", [])))
455
+ if len(available_objectives) < 2:
456
+ pytest.skip("Need at least 2 different objectives to test")
457
+
458
+ test_objectives = available_objectives[:2]
459
+ result = tester.test_multiple_objectives_filter(account_id, test_objectives)
460
+ assert result["success"], f"Failed: {result.get('error')}"
461
+ assert result["all_match_filter"], "Filter returned campaigns with wrong objectives"
462
+ print(f"Multiple objectives filter: {result['campaign_count']} campaigns")
463
+
464
+
465
+ def test_combined_filters(tester, account_id, check_server_running):
466
+ """Test filtering by both status and objective"""
467
+ # First get available objectives
468
+ no_filter_result = tester.test_no_filtering(account_id)
469
+ assert no_filter_result["success"]
470
+
471
+ objectives = no_filter_result.get("objectives", [])
472
+ if not objectives:
473
+ pytest.skip("No campaigns with objectives found")
474
+
475
+ test_objective = objectives[0]
476
+ result = tester.test_combined_status_and_objective_filter(
477
+ account_id,
478
+ "ACTIVE",
479
+ test_objective
480
+ )
481
+ assert result["success"], f"Failed: {result.get('error')}"
482
+ print(f"Combined filters: {result['campaign_count']} campaigns")
483
+
484
+
485
+ def test_empty_string_filter(tester, account_id, check_server_running):
486
+ """Test that empty string filter returns all campaigns"""
487
+ result = tester.test_empty_string_filter(account_id)
488
+ assert result["success"], f"Failed: {result.get('error')}"
489
+ print(f"Empty string filter: {result['campaign_count']} campaigns")
490
+
491
+
492
+ def test_empty_list_filter(tester, account_id, check_server_running):
493
+ """Test that empty list filter returns all campaigns"""
494
+ result = tester.test_empty_list_filter(account_id)
495
+ assert result["success"], f"Failed: {result.get('error')}"
496
+ print(f"Empty list filter: {result['campaign_count']} campaigns")
497
+
498
+
499
+ def main():
500
+ """Main test execution for standalone running"""
501
+ import sys
502
+
503
+ account_id = "act_701351919139047" # Default test account
504
+
505
+ tester = CampaignObjectiveFilterTester()
506
+ success = tester.run_all_tests(account_id)
507
+
508
+ if success:
509
+ print("\n🎉 All campaign objective filtering tests passed!")
510
+ sys.exit(0)
511
+ else:
512
+ print("\n⚠️ Some tests failed - see details above")
513
+ sys.exit(1)
514
+
515
+
516
+ if __name__ == "__main__":
517
+ main()
518
+
File without changes
File without changes
File without changes
File without changes
File without changes