@channel47/meta-ads-mcp 1.0.0

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.
@@ -0,0 +1,112 @@
1
+ export const PROMPT_TEMPLATES = {
2
+ creative_fatigue_check: {
3
+ name: 'creative_fatigue_check',
4
+ description: 'Check for ad creative fatigue signals and propose refresh priorities',
5
+ arguments: [
6
+ {
7
+ name: 'account_id',
8
+ description: 'Meta ad account ID (without act_ prefix)',
9
+ required: true
10
+ },
11
+ {
12
+ name: 'date_range',
13
+ description: 'Insights date preset (last_7d, last_30d, today, yesterday)',
14
+ required: false,
15
+ default: 'last_7d'
16
+ }
17
+ ],
18
+ template: `Run a creative fatigue analysis for Meta account {{account_id}} using {{date_range}} data:
19
+
20
+ 1. Pull ad-level insights (spend, impressions, clicks, ctr, frequency).
21
+ 2. Flag ads with high frequency and declining CTR.
22
+ 3. Group fatigued ads by campaign/ad set.
23
+ 4. Identify top creatives to refresh first by wasted spend.
24
+ 5. Return prioritized creative refresh recommendations with rationale.`
25
+ },
26
+ audience_overlap_check: {
27
+ name: 'audience_overlap_check',
28
+ description: 'Review audience definitions and identify overlap/conflict risk',
29
+ arguments: [
30
+ {
31
+ name: 'account_id',
32
+ description: 'Meta ad account ID (without act_ prefix)',
33
+ required: true
34
+ }
35
+ ],
36
+ template: `Run an audience overlap check for Meta account {{account_id}}:
37
+
38
+ 1. Pull active ad sets with targeting details.
39
+ 2. Pull custom audiences in the account.
40
+ 3. Identify segments likely competing in auction (same geo/age/interests/lookalikes).
41
+ 4. Flag exclusions that are missing and likely to cause overlap.
42
+ 5. Return concrete audience restructuring suggestions.`
43
+ },
44
+ spend_pacing_check: {
45
+ name: 'spend_pacing_check',
46
+ description: 'Check pacing against recent spend and budget signals',
47
+ arguments: [
48
+ {
49
+ name: 'account_id',
50
+ description: 'Meta ad account ID (without act_ prefix)',
51
+ required: true
52
+ },
53
+ {
54
+ name: 'date_range',
55
+ description: 'Insights date preset',
56
+ required: false,
57
+ default: 'last_7d'
58
+ }
59
+ ],
60
+ template: `Run a spend pacing check for Meta account {{account_id}} over {{date_range}}:
61
+
62
+ 1. Pull campaign-level insights and budget fields.
63
+ 2. Compare current spend velocity to available daily/lifetime budgets.
64
+ 3. Flag campaigns underpacing and overpacing.
65
+ 4. Highlight campaigns spending with weak conversion signals.
66
+ 5. Return top pacing issues and recommended budget actions.`
67
+ }
68
+ };
69
+
70
+ export function getPromptsList() {
71
+ return Object.values(PROMPT_TEMPLATES).map((prompt) => ({
72
+ name: prompt.name,
73
+ description: prompt.description,
74
+ arguments: prompt.arguments
75
+ }));
76
+ }
77
+
78
+ export function renderPrompt(name, args = {}) {
79
+ const template = PROMPT_TEMPLATES[name];
80
+ if (!template) {
81
+ throw new Error(`Unknown prompt: ${name}. Available prompts: ${Object.keys(PROMPT_TEMPLATES).join(', ')}`);
82
+ }
83
+
84
+ const missing = template.arguments
85
+ .filter((argument) => argument.required && !args[argument.name])
86
+ .map((argument) => argument.name);
87
+
88
+ if (missing.length > 0) {
89
+ throw new Error(`Missing required arguments for prompt "${name}": ${missing.join(', ')}`);
90
+ }
91
+
92
+ let text = template.template;
93
+
94
+ for (const argument of template.arguments) {
95
+ const value = args[argument.name] ?? argument.default;
96
+ if (value !== undefined) {
97
+ text = text.replace(new RegExp(`\\{\\{${argument.name}\\}\\}`, 'g'), String(value));
98
+ }
99
+ }
100
+
101
+ return {
102
+ messages: [
103
+ {
104
+ role: 'user',
105
+ content: {
106
+ type: 'text',
107
+ text
108
+ }
109
+ }
110
+ ]
111
+ };
112
+ }
@@ -0,0 +1,134 @@
1
+ # Meta Ads Entity Field Reference
2
+
3
+ This document summarizes the default fields returned by the `query` tool for each entity and notes common additions.
4
+
5
+ ## campaigns
6
+
7
+ Default:
8
+
9
+ - `id`
10
+ - `name`
11
+ - `status`
12
+ - `objective`
13
+ - `daily_budget`
14
+ - `lifetime_budget`
15
+ - `buying_type`
16
+ - `bid_strategy`
17
+ - `effective_status`
18
+
19
+ Common additions:
20
+
21
+ - `start_time`
22
+ - `stop_time`
23
+ - `special_ad_categories`
24
+
25
+ ## adsets
26
+
27
+ Default:
28
+
29
+ - `id`
30
+ - `name`
31
+ - `status`
32
+ - `campaign_id`
33
+ - `daily_budget`
34
+ - `targeting`
35
+ - `optimization_goal`
36
+ - `billing_event`
37
+ - `effective_status`
38
+
39
+ Common additions:
40
+
41
+ - `lifetime_budget`
42
+ - `promoted_object`
43
+ - `attribution_spec`
44
+
45
+ ## ads
46
+
47
+ Default:
48
+
49
+ - `id`
50
+ - `name`
51
+ - `status`
52
+ - `adset_id`
53
+ - `creative{id,name,title,body,image_url,video_id}`
54
+ - `effective_status`
55
+
56
+ Common additions:
57
+
58
+ - `tracking_specs`
59
+ - `conversion_specs`
60
+ - `preview_shareable_link`
61
+
62
+ ## insights
63
+
64
+ Default:
65
+
66
+ - `spend`
67
+ - `impressions`
68
+ - `clicks`
69
+ - `ctr`
70
+ - `cpm`
71
+ - `cpc`
72
+ - `conversions`
73
+ - `cost_per_action_type`
74
+ - `frequency`
75
+ - `reach`
76
+ - `actions`
77
+
78
+ Common additions:
79
+
80
+ - `inline_link_clicks`
81
+ - `purchase_roas`
82
+ - `video_play_actions`
83
+
84
+ ## audiences (`customaudiences`)
85
+
86
+ Default:
87
+
88
+ - `id`
89
+ - `name`
90
+ - `subtype`
91
+ - `approximate_count`
92
+ - `data_source`
93
+ - `delivery_status`
94
+
95
+ Common additions:
96
+
97
+ - `retention_days`
98
+ - `operation_status`
99
+ - `lookalike_spec`
100
+
101
+ ## creatives (`adcreatives`)
102
+
103
+ Default:
104
+
105
+ - `id`
106
+ - `name`
107
+ - `title`
108
+ - `body`
109
+ - `image_url`
110
+ - `video_id`
111
+ - `object_story_spec`
112
+
113
+ Common additions:
114
+
115
+ - `object_type`
116
+ - `thumbnail_url`
117
+ - `asset_feed_spec`
118
+
119
+ ## Inline Insights on Non-Insights Entities
120
+
121
+ For `campaigns`, `adsets`, `ads`, `audiences`, or `creatives`, you can append nested insights by passing `inline_insights_fields`.
122
+
123
+ Example:
124
+
125
+ ```json
126
+ {
127
+ "entity": "ads",
128
+ "inline_insights_fields": ["spend", "ctr", "frequency"]
129
+ }
130
+ ```
131
+
132
+ This produces a nested field projection similar to:
133
+
134
+ `insights{spend,ctr,frequency}`
@@ -0,0 +1,78 @@
1
+ # Meta Ads Graph API Quick Reference
2
+
3
+ ## Base URL
4
+
5
+ `https://graph.facebook.com/v25.0`
6
+
7
+ ## Authentication
8
+
9
+ - Auth is provided via query param: `access_token=<TOKEN>`
10
+ - This server reads token from `META_ADS_ACCESS_TOKEN`
11
+ - No token refresh flow in v1 (supports long-lived user tokens and system user tokens)
12
+
13
+ ## Account Discovery
14
+
15
+ - List accounts: `GET /me/adaccounts`
16
+ - Suggested fields:
17
+ - `id,name,account_status,currency,timezone_name,business{name}`
18
+
19
+ ## Query Endpoints (by Account)
20
+
21
+ Use normalized account IDs in tools (`1234567890`) and API paths with `act_` prefix:
22
+
23
+ - Campaigns: `GET /act_<ACCOUNT_ID>/campaigns`
24
+ - Ad sets: `GET /act_<ACCOUNT_ID>/adsets`
25
+ - Ads: `GET /act_<ACCOUNT_ID>/ads`
26
+ - Insights: `GET /act_<ACCOUNT_ID>/insights`
27
+ - Audiences: `GET /act_<ACCOUNT_ID>/customaudiences`
28
+ - Creatives: `GET /act_<ACCOUNT_ID>/adcreatives`
29
+
30
+ Useful query params:
31
+
32
+ - `fields`: comma-separated field list
33
+ - `limit`: up to 1000
34
+ - `after`: pagination cursor
35
+ - `filtering`: JSON-encoded filter array
36
+ - `sort`: sort expression
37
+
38
+ Insights-only params:
39
+
40
+ - `time_range`: JSON object (`{ "since": "YYYY-MM-DD", "until": "YYYY-MM-DD" }`)
41
+ - `level`: `campaign` | `adset` | `ad`
42
+ - `time_increment`: `1`, `7`, `monthly`, etc.
43
+
44
+ ## Mutations
45
+
46
+ Typical write patterns:
47
+
48
+ - Create: `POST /act_<ACCOUNT_ID>/<entity_collection>`
49
+ - Update: `POST /<OBJECT_ID>`
50
+ - Pause/Enable: `POST /<OBJECT_ID>` with `status=PAUSED` or `status=ACTIVE`
51
+ - Delete: `DELETE /<OBJECT_ID>`
52
+
53
+ Supported entity collections in this server:
54
+
55
+ - Campaign: `campaigns`
56
+ - Ad set: `adsets`
57
+ - Ad: `ads`
58
+ - Audience: `customaudiences`
59
+
60
+ ## Error Handling
61
+
62
+ Retry strategy in this server:
63
+
64
+ - Retry once on `401 Unauthorized`
65
+ - Retry once on throttling:
66
+ - HTTP `429`
67
+ - Graph error codes `17` or `32`
68
+
69
+ Error message extraction priority:
70
+
71
+ 1. `payload.error.message`
72
+ 2. `payload.message`
73
+ 3. Fallback generic message
74
+
75
+ ## Notes
76
+
77
+ - Budget values are returned in minor currency units (e.g., `5000` => `$50.00` USD)
78
+ - Graph API version is pinned in code (`v25.0`) for predictable behavior
@@ -0,0 +1,65 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ const __filename = fileURLToPath(import.meta.url);
6
+ const __dirname = dirname(__filename);
7
+
8
+ /**
9
+ * Static list of resource documents exposed by the server.
10
+ */
11
+ export const RESOURCES = [
12
+ {
13
+ uri: 'metaads://reference',
14
+ name: 'Meta Ads Graph API Reference',
15
+ description: 'Core Graph API endpoints, query parameters, and error handling notes for Meta Ads workflows',
16
+ mimeType: 'text/markdown'
17
+ },
18
+ {
19
+ uri: 'metaads://entity-fields',
20
+ name: 'Meta Ads Entity Field Reference',
21
+ description: 'Default fields and common optional field expansions for queryable entities',
22
+ mimeType: 'text/markdown'
23
+ },
24
+ {
25
+ uri: 'metaads://rate-limits',
26
+ name: 'Meta Ads Rate Limits and Quotas',
27
+ description: 'Rate limiting behavior, backoff guidance, and operational recommendations',
28
+ mimeType: 'text/markdown'
29
+ }
30
+ ];
31
+
32
+ const RESOURCE_FILE_MAP = {
33
+ 'metaads://reference': 'graph-api-reference.md',
34
+ 'metaads://entity-fields': 'entity-fields-reference.md',
35
+ 'metaads://rate-limits': 'rate-limits-and-quotas.md'
36
+ };
37
+
38
+ export function getResourcesList() {
39
+ return RESOURCES;
40
+ }
41
+
42
+ /**
43
+ * Read a resource document by URI.
44
+ * @param {string} uri
45
+ * @returns {{ contents: Array<{ uri: string, mimeType: string, text: string }> }}
46
+ */
47
+ export function readResource(uri) {
48
+ const filename = RESOURCE_FILE_MAP[uri];
49
+ if (!filename) {
50
+ throw new Error(`Unknown resource: ${uri}. Available: ${Object.keys(RESOURCE_FILE_MAP).join(', ')}`);
51
+ }
52
+
53
+ const filePath = join(__dirname, filename);
54
+ const content = readFileSync(filePath, 'utf8');
55
+
56
+ return {
57
+ contents: [
58
+ {
59
+ uri,
60
+ mimeType: 'text/markdown',
61
+ text: content
62
+ }
63
+ ]
64
+ };
65
+ }
@@ -0,0 +1,33 @@
1
+ # Meta Ads Rate Limits and Quotas
2
+
3
+ Meta Ads API enforces usage-based throttling. Limits are not fixed per endpoint; they depend on workload, account state, and system pressure.
4
+
5
+ ## What This MCP Server Does
6
+
7
+ - Treats HTTP `429` and Graph error codes `17` and `32` as throttling signals.
8
+ - Reads `Retry-After` response header when available.
9
+ - Falls back to `60` seconds if `Retry-After` is missing or invalid.
10
+ - Retries throttled requests once.
11
+
12
+ ## Backoff Behavior
13
+
14
+ `Retry-After` values may be:
15
+
16
+ - Integer seconds (for example `7`)
17
+ - HTTP date string
18
+
19
+ The server converts this to milliseconds and sleeps before a single retry.
20
+
21
+ ## Timeout Behavior
22
+
23
+ - Requests use a default timeout of `30000` ms.
24
+ - Override with `META_ADS_REQUEST_TIMEOUT_MS` environment variable.
25
+ - Timeout errors are surfaced as request failures so callers can retry or degrade gracefully.
26
+
27
+ ## Operational Guidance
28
+
29
+ - Keep requested field sets narrow.
30
+ - Use `limit` deliberately and paginate only as needed.
31
+ - Avoid broad fan-out query loops across many accounts at once.
32
+ - Cache read-heavy metadata (account and entity lists) where possible.
33
+ - Stagger high-volume jobs to reduce burst pressure.
@@ -0,0 +1,63 @@
1
+ import { metaRequest } from '../http.js';
2
+ import { fetchAllPages } from '../utils/paginate.js';
3
+ import { formatError, formatSuccess } from '../utils/response-format.js';
4
+ import { normalizeAccountId } from '../utils/validation.js';
5
+
6
+ const ACCOUNT_STATUS_MAP = {
7
+ 1: 'ACTIVE',
8
+ 2: 'DISABLED',
9
+ 3: 'UNSETTLED',
10
+ 7: 'PENDING_REVIEW',
11
+ 8: 'PENDING_CLOSURE',
12
+ 9: 'IN_GRACE_PERIOD',
13
+ 100: 'PENDING_RISK_REVIEW'
14
+ };
15
+
16
+ function normalizeStatus(code) {
17
+ return ACCOUNT_STATUS_MAP[Number(code)] || 'UNKNOWN';
18
+ }
19
+
20
+ function mapAccount(account) {
21
+ return {
22
+ id: normalizeAccountId(account?.id),
23
+ name: account?.name || '',
24
+ account_status: Number(account?.account_status || 0),
25
+ status: normalizeStatus(account?.account_status),
26
+ currency: account?.currency || null,
27
+ timezone_name: account?.timezone_name || null,
28
+ business_name: account?.business?.name || null
29
+ };
30
+ }
31
+
32
+ /**
33
+ * List all accessible ad accounts for the authenticated user token.
34
+ * @param {{ status?: string }} [params]
35
+ * @param {{ request?: (path: string, params: Record<string, unknown>) => Promise<any> }} [dependencies]
36
+ * @returns {Promise<import('@modelcontextprotocol/sdk/types.js').CallToolResult>}
37
+ */
38
+ export async function listAccounts(params = {}, dependencies = {}) {
39
+ const request = dependencies.request || metaRequest;
40
+
41
+ try {
42
+ const allPages = await fetchAllPages(request, '/me/adaccounts', {
43
+ fields: 'id,name,account_status,currency,timezone_name,business{name}'
44
+ });
45
+ const allAccounts = allPages.map(mapAccount);
46
+ const statusFilter = params.status ? String(params.status).toUpperCase() : null;
47
+
48
+ const filteredAccounts = statusFilter
49
+ ? allAccounts.filter((account) => account.status === statusFilter)
50
+ : allAccounts;
51
+
52
+ return formatSuccess({
53
+ summary: `Found ${filteredAccounts.length} accessible Meta ad account${filteredAccounts.length === 1 ? '' : 's'}`,
54
+ data: filteredAccounts,
55
+ metadata: {
56
+ totalAccounts: filteredAccounts.length,
57
+ appliedStatusFilter: statusFilter
58
+ }
59
+ });
60
+ } catch (error) {
61
+ return formatError(error);
62
+ }
63
+ }
@@ -0,0 +1,179 @@
1
+ import { metaRequest } from '../http.js';
2
+ import { formatError, formatSuccess } from '../utils/response-format.js';
3
+ import { invalidParamsError } from '../utils/errors.js';
4
+ import { getAccountId } from '../utils/validation.js';
5
+ import {
6
+ buildApiRequest,
7
+ buildRequestPreview,
8
+ validateOperations
9
+ } from '../utils/mutate-operations.js';
10
+
11
+ /**
12
+ * Validate and execute Meta Ads mutation operations with dry-run safety defaults.
13
+ * @param {Record<string, unknown>} [params]
14
+ * @param {{ request?: (path: string, params: Record<string, unknown>, options?: Record<string, unknown>) => Promise<any> }} [dependencies]
15
+ * @returns {Promise<import('@modelcontextprotocol/sdk/types.js').CallToolResult>}
16
+ */
17
+ export async function mutate(params = {}, dependencies = {}) {
18
+ const request = dependencies.request || metaRequest;
19
+
20
+ try {
21
+ const operations = params.operations;
22
+ const accountId = getAccountId(params);
23
+ const partialFailure = params.partial_failure === undefined ? true : Boolean(params.partial_failure);
24
+ const dryRun = params.dry_run === undefined ? true : Boolean(params.dry_run);
25
+
26
+ const validationErrors = validateOperations(operations);
27
+ if (validationErrors.length > 0) {
28
+ const errorMessage = validationErrors.map((issue) => `[${issue.index}] ${issue.message}`).join('; ');
29
+ throw invalidParamsError(`Invalid operations: ${errorMessage}`);
30
+ }
31
+
32
+ if (dryRun) {
33
+ const validationResults = [];
34
+ let serverValidated = true;
35
+
36
+ try {
37
+ for (let index = 0; index < operations.length; index += 1) {
38
+ const operation = operations[index];
39
+ const apiRequest = buildApiRequest(operation, accountId);
40
+
41
+ if (operation.action === 'create' || operation.action === 'update') {
42
+ const validateParams = {
43
+ ...apiRequest.params,
44
+ execution_options: ['validate_only', 'include_recommendations']
45
+ };
46
+
47
+ try {
48
+ const response = await request(apiRequest.path, validateParams, {
49
+ method: apiRequest.method
50
+ });
51
+ validationResults.push({
52
+ index,
53
+ entity: operation.entity,
54
+ action: operation.action,
55
+ valid: true,
56
+ recommendations: response?.recommendations || null
57
+ });
58
+ } catch (opError) {
59
+ validationResults.push({
60
+ index,
61
+ entity: operation.entity,
62
+ action: operation.action,
63
+ valid: false,
64
+ error: opError.message
65
+ });
66
+
67
+ if (!partialFailure) {
68
+ break;
69
+ }
70
+ }
71
+ } else {
72
+ validationResults.push({
73
+ index,
74
+ entity: operation.entity,
75
+ action: operation.action,
76
+ valid: true,
77
+ note: `${operation.action} validated client-side only`
78
+ });
79
+ }
80
+ }
81
+ } catch (outerError) {
82
+ serverValidated = false;
83
+ const preview = buildRequestPreview(operations, accountId);
84
+ return formatSuccess({
85
+ summary: `Dry run: ${operations.length} operation(s) validated client-side only (server validation failed: ${outerError.message}). ${preview.requests.length} request(s) would be sent.`,
86
+ data: preview.requests,
87
+ metadata: {
88
+ dryRun: true,
89
+ serverValidated: false,
90
+ warning: `Server-side validation unavailable: ${outerError.message}`,
91
+ operationCount: operations.length,
92
+ apiCallCount: preview.requests.length,
93
+ accountId
94
+ }
95
+ });
96
+ }
97
+
98
+ const validCount = validationResults.filter((r) => r.valid).length;
99
+ const invalidCount = validationResults.filter((r) => !r.valid).length;
100
+
101
+ return formatSuccess({
102
+ summary: `Dry run: ${operations.length} operation(s) validated server-side via Meta API. ${validCount} valid, ${invalidCount} invalid. No changes applied.`,
103
+ data: validationResults,
104
+ metadata: {
105
+ dryRun: true,
106
+ serverValidated: true,
107
+ operationCount: operations.length,
108
+ validCount,
109
+ invalidCount,
110
+ accountId
111
+ }
112
+ });
113
+ }
114
+
115
+ if (process.env.META_ADS_READ_ONLY === 'true') {
116
+ throw new Error('mutate is disabled in read-only mode (META_ADS_READ_ONLY=true)');
117
+ }
118
+
119
+ const results = [];
120
+ let succeeded = 0;
121
+ let failed = 0;
122
+
123
+ for (let index = 0; index < operations.length; index += 1) {
124
+ const operation = operations[index];
125
+ const apiRequest = buildApiRequest(operation, accountId);
126
+
127
+ try {
128
+ const response = await request(apiRequest.path, apiRequest.params, {
129
+ method: apiRequest.method
130
+ });
131
+
132
+ const resolvedId = response?.id || operation.id || null;
133
+ results.push({
134
+ index,
135
+ entity: operation.entity,
136
+ action: operation.action,
137
+ success: true,
138
+ id: resolvedId,
139
+ response
140
+ });
141
+ succeeded += 1;
142
+ } catch (error) {
143
+ results.push({
144
+ index,
145
+ entity: operation.entity,
146
+ action: operation.action,
147
+ success: false,
148
+ error: {
149
+ message: error.message
150
+ }
151
+ });
152
+ failed += 1;
153
+
154
+ if (!partialFailure) {
155
+ break;
156
+ }
157
+ }
158
+ }
159
+
160
+ if (succeeded === 0 && failed > 0) {
161
+ const firstError = results.find((entry) => !entry.success)?.error?.message || 'Unknown';
162
+ throw new Error(`All ${failed} operation(s) failed. First error: ${firstError}`);
163
+ }
164
+
165
+ return formatSuccess({
166
+ summary: `Executed ${results.length} operation(s): ${succeeded} succeeded, ${failed} failed`,
167
+ data: results,
168
+ metadata: {
169
+ dryRun: false,
170
+ operationCount: results.length,
171
+ succeeded,
172
+ failed,
173
+ accountId
174
+ }
175
+ });
176
+ } catch (error) {
177
+ return formatError(error);
178
+ }
179
+ }