@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,157 @@
1
+ import { metaRequest } from '../http.js';
2
+ import { fetchAllPages } from '../utils/paginate.js';
3
+ import { formatError, formatSuccess } from '../utils/response-format.js';
4
+ import {
5
+ ENTITY_FIELDS,
6
+ SUPPORTED_ENTITIES,
7
+ resolveInsightsDateRange
8
+ } from '../utils/field-defaults.js';
9
+ import { getAccountId, validateEnum } from '../utils/validation.js';
10
+
11
+ const ENTITY_PATHS = {
12
+ campaigns: 'campaigns',
13
+ adsets: 'adsets',
14
+ ads: 'ads',
15
+ insights: 'insights',
16
+ audiences: 'customaudiences',
17
+ creatives: 'adcreatives'
18
+ };
19
+
20
+ function serializeField(field) {
21
+ if (typeof field === 'string') {
22
+ return field;
23
+ }
24
+
25
+ if (field && typeof field === 'object' && !Array.isArray(field)) {
26
+ const [name, nested] = Object.entries(field)[0] || [];
27
+ if (!name) {
28
+ return '';
29
+ }
30
+ return `${name}{${serializeFields(nested)}}`;
31
+ }
32
+
33
+ return String(field);
34
+ }
35
+
36
+ // Supports both simple fields ("id") and nested projections ({ creative: [...] }).
37
+ function serializeFields(fields) {
38
+ if (Array.isArray(fields)) {
39
+ return fields.map(serializeField).filter(Boolean).join(',');
40
+ }
41
+
42
+ if (fields && typeof fields === 'object') {
43
+ return serializeField(fields);
44
+ }
45
+
46
+ return String(fields || '');
47
+ }
48
+
49
+ function getRequestedFields(entity, fields) {
50
+ return serializeFields(fields || ENTITY_FIELDS[entity]);
51
+ }
52
+
53
+ function getInlineInsightsFields(value) {
54
+ if (value === undefined || value === null || value === '') {
55
+ return [];
56
+ }
57
+
58
+ if (Array.isArray(value)) {
59
+ return value.map((field) => String(field).trim()).filter(Boolean);
60
+ }
61
+
62
+ return String(value)
63
+ .split(',')
64
+ .map((field) => field.trim())
65
+ .filter(Boolean);
66
+ }
67
+
68
+ function getRequestedLimit(limitValue) {
69
+ if (limitValue === undefined || limitValue === null || limitValue === '') {
70
+ return 100;
71
+ }
72
+
73
+ const parsed = Number(limitValue);
74
+ if (!Number.isFinite(parsed) || parsed <= 0) {
75
+ return 100;
76
+ }
77
+
78
+ return Math.min(Math.floor(parsed), 1000);
79
+ }
80
+
81
+ function buildBaseParams(params, entity, limit) {
82
+ let fields = getRequestedFields(entity, params.fields);
83
+
84
+ if (entity !== 'insights') {
85
+ const inlineInsightsFields = getInlineInsightsFields(params.inline_insights_fields);
86
+ if (inlineInsightsFields.length > 0 && !fields.includes('insights{')) {
87
+ fields = `${fields},insights{${inlineInsightsFields.join(',')}}`;
88
+ }
89
+ }
90
+
91
+ const queryParams = {
92
+ fields,
93
+ limit: String(limit)
94
+ };
95
+
96
+ if (Array.isArray(params.filters) && params.filters.length > 0) {
97
+ queryParams.filtering = JSON.stringify(params.filters);
98
+ }
99
+
100
+ if (params.sort) {
101
+ queryParams.sort = String(params.sort);
102
+ }
103
+
104
+ if (entity === 'insights') {
105
+ const timeRange = resolveInsightsDateRange(params.date_range);
106
+ queryParams.time_range = JSON.stringify(timeRange);
107
+
108
+ if (params.level) {
109
+ queryParams.level = String(params.level);
110
+ }
111
+
112
+ if (params.time_increment !== undefined && params.time_increment !== null) {
113
+ queryParams.time_increment = String(params.time_increment);
114
+ }
115
+
116
+ if (Array.isArray(params.breakdowns) && params.breakdowns.length > 0) {
117
+ queryParams.breakdowns = params.breakdowns.join(',');
118
+ }
119
+ }
120
+
121
+ return queryParams;
122
+ }
123
+
124
+ /**
125
+ * Query Meta Ads entities with cursor pagination and entity-specific parameters.
126
+ * @param {Record<string, unknown>} [params]
127
+ * @param {{ request?: (path: string, params: Record<string, unknown>) => Promise<any> }} [dependencies]
128
+ * @returns {Promise<import('@modelcontextprotocol/sdk/types.js').CallToolResult>}
129
+ */
130
+ export async function query(params = {}, dependencies = {}) {
131
+ const request = dependencies.request || metaRequest;
132
+
133
+ try {
134
+ validateEnum(params.entity, SUPPORTED_ENTITIES, 'entity');
135
+
136
+ const accountId = getAccountId(params);
137
+ const entity = params.entity;
138
+ const requestedLimit = getRequestedLimit(params.limit);
139
+ const path = `/act_${accountId}/${ENTITY_PATHS[entity]}`;
140
+
141
+ const queryParams = buildBaseParams(params, entity, requestedLimit);
142
+ const rows = await fetchAllPages(request, path, queryParams, requestedLimit);
143
+
144
+ return formatSuccess({
145
+ summary: `Returned ${rows.length} ${entity} row${rows.length === 1 ? '' : 's'}`,
146
+ data: rows,
147
+ metadata: {
148
+ entity,
149
+ accountId,
150
+ limit: requestedLimit,
151
+ returned: rows.length
152
+ }
153
+ });
154
+ } catch (error) {
155
+ return formatError(error);
156
+ }
157
+ }
@@ -0,0 +1,12 @@
1
+ function createTypedError(code, message) {
2
+ const error = new Error(message);
3
+ error.mcpCode = code;
4
+ return error;
5
+ }
6
+
7
+ /**
8
+ * Create an InvalidParams typed error.
9
+ */
10
+ export function invalidParamsError(message) {
11
+ return createTypedError('InvalidParams', message);
12
+ }
@@ -0,0 +1,164 @@
1
+ import { invalidParamsError } from './errors.js';
2
+
3
+ /**
4
+ * Default field projections per query entity.
5
+ */
6
+ export const ENTITY_FIELDS = {
7
+ campaigns: [
8
+ 'id',
9
+ 'name',
10
+ 'status',
11
+ 'objective',
12
+ 'daily_budget',
13
+ 'lifetime_budget',
14
+ 'buying_type',
15
+ 'bid_strategy',
16
+ 'effective_status'
17
+ ],
18
+ adsets: [
19
+ 'id',
20
+ 'name',
21
+ 'status',
22
+ 'campaign_id',
23
+ 'daily_budget',
24
+ 'targeting',
25
+ 'optimization_goal',
26
+ 'billing_event',
27
+ 'effective_status'
28
+ ],
29
+ ads: [
30
+ 'id',
31
+ 'name',
32
+ 'status',
33
+ 'adset_id',
34
+ { creative: ['id', 'name', 'title', 'body', 'image_url', 'video_id'] },
35
+ 'effective_status'
36
+ ],
37
+ insights: [
38
+ 'spend',
39
+ 'impressions',
40
+ 'clicks',
41
+ 'ctr',
42
+ 'cpm',
43
+ 'cpc',
44
+ 'conversions',
45
+ 'cost_per_action_type',
46
+ 'frequency',
47
+ 'reach',
48
+ 'actions'
49
+ ],
50
+ audiences: [
51
+ 'id',
52
+ 'name',
53
+ 'subtype',
54
+ 'approximate_count',
55
+ 'data_source',
56
+ 'delivery_status'
57
+ ],
58
+ creatives: [
59
+ 'id',
60
+ 'name',
61
+ 'title',
62
+ 'body',
63
+ 'image_url',
64
+ 'video_id',
65
+ 'object_story_spec'
66
+ ]
67
+ };
68
+
69
+ export const SUPPORTED_ENTITIES = Object.keys(ENTITY_FIELDS);
70
+
71
+ /**
72
+ * Supported date presets for insights queries.
73
+ */
74
+ export const INSIGHTS_PRESETS = {
75
+ today: { sinceOffsetDays: 0, untilOffsetDays: 0 },
76
+ yesterday: { sinceOffsetDays: -1, untilOffsetDays: -1 },
77
+ last_14d: { sinceOffsetDays: -13, untilOffsetDays: 0 },
78
+ last_7d: { sinceOffsetDays: -6, untilOffsetDays: 0 },
79
+ last_30d: { sinceOffsetDays: -29, untilOffsetDays: 0 },
80
+ this_month: (now) => ({
81
+ since: toIsoDateUtc(new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1))),
82
+ until: toIsoDateUtc(now)
83
+ }),
84
+ this_week_mon_today: (now) => {
85
+ const day = now.getUTCDay();
86
+ const daysSinceMonday = day === 0 ? 6 : day - 1;
87
+ return {
88
+ since: toIsoDateUtc(offsetUtcDays(now, -daysSinceMonday)),
89
+ until: toIsoDateUtc(now)
90
+ };
91
+ },
92
+ last_quarter: (now) => {
93
+ const currentQuarter = Math.floor(now.getUTCMonth() / 3);
94
+ const lastQuarter = currentQuarter === 0 ? 3 : currentQuarter - 1;
95
+ const year = currentQuarter === 0 ? now.getUTCFullYear() - 1 : now.getUTCFullYear();
96
+ const startMonth = lastQuarter * 3;
97
+
98
+ return {
99
+ since: toIsoDateUtc(new Date(Date.UTC(year, startMonth, 1))),
100
+ until: toIsoDateUtc(new Date(Date.UTC(year, startMonth + 3, 0)))
101
+ };
102
+ }
103
+ };
104
+
105
+ function toIsoDateUtc(date) {
106
+ return date.toISOString().slice(0, 10);
107
+ }
108
+
109
+ function offsetUtcDays(date, days) {
110
+ const copy = new Date(date);
111
+ copy.setUTCDate(copy.getUTCDate() + days);
112
+ return copy;
113
+ }
114
+
115
+ function validateDate(value, label) {
116
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
117
+ throw invalidParamsError(`Invalid ${label} date format: ${value}. Expected YYYY-MM-DD`);
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Resolve a date_range preset or explicit range object to { since, until } (UTC date strings).
123
+ * @param {string | { since: string, until: string } | undefined} dateRange
124
+ * @param {Date} [now]
125
+ * @returns {{ since: string, until: string }}
126
+ */
127
+ export function resolveInsightsDateRange(dateRange, now = new Date()) {
128
+ if (!dateRange) {
129
+ return resolveInsightsDateRange('last_7d', now);
130
+ }
131
+
132
+ if (typeof dateRange === 'string') {
133
+ const presetKey = dateRange.toLowerCase();
134
+ const preset = INSIGHTS_PRESETS[presetKey];
135
+ if (!preset) {
136
+ throw invalidParamsError(`Invalid date_range preset: ${dateRange}. Allowed: ${Object.keys(INSIGHTS_PRESETS).join(', ')}`);
137
+ }
138
+
139
+ if (typeof preset === 'function') {
140
+ return preset(now);
141
+ }
142
+
143
+ return {
144
+ since: toIsoDateUtc(offsetUtcDays(now, preset.sinceOffsetDays)),
145
+ until: toIsoDateUtc(offsetUtcDays(now, preset.untilOffsetDays))
146
+ };
147
+ }
148
+
149
+ if (typeof dateRange === 'object') {
150
+ const since = dateRange.since;
151
+ const until = dateRange.until;
152
+
153
+ if (!since || !until) {
154
+ throw invalidParamsError('date_range object must include both since and until');
155
+ }
156
+
157
+ validateDate(since, 'since');
158
+ validateDate(until, 'until');
159
+
160
+ return { since, until };
161
+ }
162
+
163
+ throw invalidParamsError('date_range must be a preset string or { since, until } object');
164
+ }
@@ -0,0 +1,149 @@
1
+ import { validateArray, withActPrefix } from './validation.js';
2
+
3
+ /**
4
+ * Supported mutate entity identifiers.
5
+ */
6
+ export const SUPPORTED_MUTATE_ENTITIES = ['campaign', 'adset', 'ad', 'audience', 'creative'];
7
+ /**
8
+ * Supported mutate action identifiers.
9
+ */
10
+ export const SUPPORTED_MUTATE_ACTIONS = ['create', 'update', 'pause', 'enable', 'archive', 'delete'];
11
+
12
+ const CREATE_ENTITY_PATHS = {
13
+ campaign: 'campaigns',
14
+ adset: 'adsets',
15
+ ad: 'ads',
16
+ audience: 'customaudiences',
17
+ creative: 'adcreatives'
18
+ };
19
+
20
+ const STATUS_BY_ACTION = {
21
+ pause: 'PAUSED',
22
+ enable: 'ACTIVE',
23
+ archive: 'ARCHIVED'
24
+ };
25
+
26
+ function validateOperationShape(op, index) {
27
+ if (!op || typeof op !== 'object' || Array.isArray(op)) {
28
+ return { index, message: 'Operation must be an object' };
29
+ }
30
+
31
+ if (!op.entity) {
32
+ return { index, message: 'Missing required field: entity' };
33
+ }
34
+
35
+ if (!SUPPORTED_MUTATE_ENTITIES.includes(op.entity)) {
36
+ return {
37
+ index,
38
+ message: `Unsupported entity: ${op.entity}. Supported: ${SUPPORTED_MUTATE_ENTITIES.join(', ')}`
39
+ };
40
+ }
41
+
42
+ if (!op.action) {
43
+ return { index, message: 'Missing required field: action' };
44
+ }
45
+
46
+ if (!SUPPORTED_MUTATE_ACTIONS.includes(op.action)) {
47
+ return {
48
+ index,
49
+ message: `Unsupported action: ${op.action}. Supported: ${SUPPORTED_MUTATE_ACTIONS.join(', ')}`
50
+ };
51
+ }
52
+
53
+ if ((op.action === 'create' || op.action === 'update') && (!op.params || typeof op.params !== 'object' || Array.isArray(op.params))) {
54
+ return {
55
+ index,
56
+ message: `${op.action} requires params object`
57
+ };
58
+ }
59
+
60
+ if (op.action !== 'create' && !op.id) {
61
+ return {
62
+ index,
63
+ message: `${op.action} requires id`
64
+ };
65
+ }
66
+
67
+ return null;
68
+ }
69
+
70
+ /**
71
+ * Validate mutate operation array shape and action/entity compatibility.
72
+ * @param {unknown} operations
73
+ * @returns {Array<{ index: number, message: string }>}
74
+ */
75
+ export function validateOperations(operations) {
76
+ validateArray(operations, 'operations');
77
+
78
+ const errors = [];
79
+ for (let index = 0; index < operations.length; index += 1) {
80
+ const issue = validateOperationShape(operations[index], index);
81
+ if (issue) {
82
+ errors.push(issue);
83
+ }
84
+ }
85
+
86
+ return errors;
87
+ }
88
+
89
+ /**
90
+ * Build a Meta Graph API request payload from a single mutate operation.
91
+ * @param {{ entity: string, action: string, id?: string, params?: Record<string, unknown> }} operation
92
+ * @param {string} accountId
93
+ * @returns {{ method: string, path: string, params: Record<string, unknown> }}
94
+ */
95
+ export function buildApiRequest(operation, accountId) {
96
+ const action = operation.action;
97
+
98
+ if (action === 'create') {
99
+ const parentPath = CREATE_ENTITY_PATHS[operation.entity];
100
+ return {
101
+ method: 'POST',
102
+ path: `/${withActPrefix(accountId)}/${parentPath}`,
103
+ params: {
104
+ status: 'PAUSED',
105
+ ...operation.params
106
+ }
107
+ };
108
+ }
109
+
110
+ if (action === 'delete') {
111
+ return {
112
+ method: 'DELETE',
113
+ path: `/${operation.id}`,
114
+ params: {}
115
+ };
116
+ }
117
+
118
+ const status = STATUS_BY_ACTION[action];
119
+ return {
120
+ method: 'POST',
121
+ path: `/${operation.id}`,
122
+ params: {
123
+ ...(status ? { status } : {}),
124
+ ...(operation.params || {})
125
+ }
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Build a human-readable preview of API requests derived from operations.
131
+ * @param {Array<{ entity: string, action: string, id?: string, params?: Record<string, unknown> }>} operations
132
+ * @param {string} accountId
133
+ * @returns {{ requests: Array<Record<string, unknown>> }}
134
+ */
135
+ export function buildRequestPreview(operations, accountId) {
136
+ return {
137
+ requests: operations.map((operation, index) => {
138
+ const request = buildApiRequest(operation, accountId);
139
+ return {
140
+ index,
141
+ entity: operation.entity,
142
+ action: operation.action,
143
+ method: request.method,
144
+ path: request.path,
145
+ params: request.params
146
+ };
147
+ })
148
+ };
149
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Follow Graph API cursor pagination, aggregating rows up to maxRows.
3
+ * @param {(path: string, params: Record<string, unknown>) => Promise<any>} request
4
+ * @param {string} path
5
+ * @param {Record<string, unknown>} [initialParams]
6
+ * @param {number} [maxRows]
7
+ * @returns {Promise<any[]>}
8
+ */
9
+ export async function fetchAllPages(request, path, initialParams = {}, maxRows = Infinity) {
10
+ const rows = [];
11
+ let after = null;
12
+
13
+ while (rows.length < maxRows) {
14
+ const pageParams = { ...initialParams };
15
+ if (after) {
16
+ pageParams.after = after;
17
+ }
18
+
19
+ const response = await request(path, pageParams);
20
+ const data = Array.isArray(response?.data) ? response.data : [];
21
+ rows.push(...data);
22
+
23
+ after = response?.paging?.cursors?.after;
24
+ if (!after) {
25
+ break;
26
+ }
27
+ }
28
+
29
+ return rows.length > maxRows ? rows.slice(0, maxRows) : rows;
30
+ }
@@ -0,0 +1,63 @@
1
+ let SdkMcpError = null;
2
+ let SdkErrorCode = null;
3
+
4
+ try {
5
+ const sdkTypes = await import('@modelcontextprotocol/sdk/types.js');
6
+ SdkMcpError = sdkTypes.McpError;
7
+ SdkErrorCode = sdkTypes.ErrorCode;
8
+ } catch {
9
+ // SDK may be absent in minimal test environments.
10
+ }
11
+
12
+ /**
13
+ * Format a successful tool result payload in MCP text content structure.
14
+ * @param {{ summary: string, data: any, metadata?: Record<string, unknown> }} payload
15
+ * @returns {import('@modelcontextprotocol/sdk/types.js').CallToolResult}
16
+ */
17
+ export function formatSuccess({ summary, data, metadata = {} }) {
18
+ return {
19
+ content: [{
20
+ type: 'text',
21
+ text: JSON.stringify({
22
+ success: true,
23
+ summary,
24
+ data,
25
+ metadata: {
26
+ rowCount: Array.isArray(data) ? data.length : undefined,
27
+ warnings: [],
28
+ ...metadata
29
+ }
30
+ }, null, 2)
31
+ }]
32
+ };
33
+ }
34
+
35
+ function createMcpStyleError(code, message) {
36
+ if (SdkMcpError && SdkErrorCode && SdkErrorCode[code]) {
37
+ return new SdkMcpError(SdkErrorCode[code], message);
38
+ }
39
+
40
+ const error = new Error(message);
41
+ error.code = code;
42
+ return error;
43
+ }
44
+
45
+ /**
46
+ * Convert thrown errors into MCP-compatible typed errors.
47
+ * @param {unknown} error
48
+ * @throws {Error}
49
+ */
50
+ export function formatError(error) {
51
+ const message = error?.message || String(error) || 'Unknown error';
52
+ const explicitCode = error?.mcpCode === 'InvalidParams' || error?.code === 'InvalidParams'
53
+ ? 'InvalidParams'
54
+ : error?.mcpCode === 'InternalError' || error?.code === 'InternalError'
55
+ ? 'InternalError'
56
+ : null;
57
+
58
+ if (explicitCode) {
59
+ throw createMcpStyleError(explicitCode, message);
60
+ }
61
+
62
+ throw createMcpStyleError('InternalError', message);
63
+ }
@@ -0,0 +1,55 @@
1
+ import { invalidParamsError } from './errors.js';
2
+
3
+ /**
4
+ * Validate an enum value against an allowed set.
5
+ */
6
+ export function validateEnum(value, allowed, paramName = 'value') {
7
+ if (!allowed.includes(value)) {
8
+ throw invalidParamsError(`Invalid ${paramName}: ${value}. Allowed values: ${allowed.join(', ')}`);
9
+ }
10
+ }
11
+
12
+ /**
13
+ * Validate that a value is a non-empty array.
14
+ */
15
+ export function validateArray(value, paramName = 'value') {
16
+ if (!Array.isArray(value) || value.length === 0) {
17
+ throw invalidParamsError(`${paramName} must be a non-empty array`);
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Normalize ad account IDs by removing optional act_ prefix.
23
+ */
24
+ export function normalizeAccountId(accountId) {
25
+ const raw = String(accountId || '').trim();
26
+ if (!raw) {
27
+ return '';
28
+ }
29
+
30
+ return raw.startsWith('act_') ? raw.slice(4) : raw;
31
+ }
32
+
33
+ /**
34
+ * Ensure ad account ID includes act_ prefix.
35
+ */
36
+ export function withActPrefix(accountId) {
37
+ const normalized = normalizeAccountId(accountId);
38
+ if (!normalized) {
39
+ return '';
40
+ }
41
+
42
+ return `act_${normalized}`;
43
+ }
44
+
45
+ /**
46
+ * Resolve account ID from params or environment, normalized without act_ prefix.
47
+ */
48
+ export function getAccountId(params = {}) {
49
+ const accountId = params.account_id || process.env.META_ADS_ACCOUNT_ID;
50
+ if (!accountId) {
51
+ throw invalidParamsError('account_id parameter or META_ADS_ACCOUNT_ID environment variable required');
52
+ }
53
+
54
+ return normalizeAccountId(accountId);
55
+ }