@channel47/pinterest-ads-mcp 0.1.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,102 @@
1
+ import { pinterestRequest } from '../http.js';
2
+ import { formatError, formatSuccess } from '../utils/response-format.js';
3
+ import { invalidParamsError } from '../utils/errors.js';
4
+ import { getAdAccountId, toIdList, validateEnum, validateRequired } from '../utils/validation.js';
5
+ import {
6
+ ANALYTICS_LEVELS,
7
+ SUPPORTED_ANALYTICS_LEVELS,
8
+ resolveColumns,
9
+ resolveGranularity,
10
+ validateDateRange
11
+ } from '../utils/analytics-params.js';
12
+
13
+ const CONVERSION_REPORT_TIMES = ['TIME_OF_AD_ACTION', 'TIME_OF_CONVERSION'];
14
+ const REPORTING_TIMEZONES = ['PINTEREST_TIME_ZONE', 'AD_ACCOUNT_TIME_ZONE'];
15
+ const WINDOW_DAYS = [0, 1, 7, 14, 30, 60];
16
+ const WINDOW_PARAMS = ['click_window_days', 'engagement_window_days', 'view_window_days'];
17
+
18
+ function applyOptionalParams(queryParams, params) {
19
+ for (const windowParam of WINDOW_PARAMS) {
20
+ const value = params[windowParam];
21
+ if (value === undefined || value === null || value === '') {
22
+ continue;
23
+ }
24
+
25
+ const parsed = Number(value);
26
+ if (!WINDOW_DAYS.includes(parsed)) {
27
+ throw invalidParamsError(`Invalid ${windowParam}: ${value}. Allowed values: ${WINDOW_DAYS.join(', ')}`);
28
+ }
29
+ queryParams[windowParam] = String(parsed);
30
+ }
31
+
32
+ if (params.conversion_report_time) {
33
+ const value = String(params.conversion_report_time).toUpperCase();
34
+ validateEnum(value, CONVERSION_REPORT_TIMES, 'conversion_report_time');
35
+ queryParams.conversion_report_time = value;
36
+ }
37
+
38
+ if (params.reporting_timezone) {
39
+ const value = String(params.reporting_timezone).toUpperCase();
40
+ validateEnum(value, REPORTING_TIMEZONES, 'reporting_timezone');
41
+ queryParams.reporting_timezone = value;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Pull Pinterest Ads analytics at account, campaign, ad_group, or ad level.
47
+ * @param {Record<string, unknown>} [params]
48
+ * @param {{ request?: (path: string, params: Record<string, unknown>) => Promise<any> }} [dependencies]
49
+ * @returns {Promise<import('@modelcontextprotocol/sdk/types.js').CallToolResult>}
50
+ */
51
+ export async function analytics(params = {}, dependencies = {}) {
52
+ const request = dependencies.request || pinterestRequest;
53
+
54
+ try {
55
+ const level = params.level === undefined || params.level === null || params.level === ''
56
+ ? 'account'
57
+ : String(params.level).toLowerCase();
58
+ validateEnum(level, SUPPORTED_ANALYTICS_LEVELS, 'level');
59
+ validateRequired(params, ['start_date', 'end_date']);
60
+
61
+ const adAccountId = getAdAccountId(params);
62
+ const { pathSuffix, idsParam } = ANALYTICS_LEVELS[level];
63
+ const { startDate, endDate } = validateDateRange(params.start_date, params.end_date);
64
+ const columns = resolveColumns(params.columns);
65
+ const granularity = resolveGranularity(params.granularity);
66
+
67
+ const queryParams = {
68
+ start_date: startDate,
69
+ end_date: endDate,
70
+ columns,
71
+ granularity
72
+ };
73
+
74
+ if (idsParam) {
75
+ const ids = toIdList(params[idsParam]);
76
+ if (ids.length === 0) {
77
+ throw invalidParamsError(`${idsParam} is required for level "${level}"`);
78
+ }
79
+ queryParams[idsParam] = ids;
80
+ }
81
+
82
+ applyOptionalParams(queryParams, params);
83
+
84
+ const response = await request(`/ad_accounts/${adAccountId}${pathSuffix}`, queryParams);
85
+ const rows = Array.isArray(response) ? response : [];
86
+
87
+ return formatSuccess({
88
+ summary: `Returned ${rows.length} ${level}-level analytics row${rows.length === 1 ? '' : 's'} for ${startDate} to ${endDate}`,
89
+ data: rows,
90
+ metadata: {
91
+ level,
92
+ adAccountId,
93
+ startDate,
94
+ endDate,
95
+ granularity,
96
+ columns
97
+ }
98
+ });
99
+ } catch (error) {
100
+ return formatError(error);
101
+ }
102
+ }
@@ -0,0 +1,68 @@
1
+ import { pinterestRequest } from '../http.js';
2
+ import { formatError, formatSuccess } from '../utils/response-format.js';
3
+
4
+ const PAGE_SIZE = 250;
5
+
6
+ function mapAccount(account) {
7
+ return {
8
+ id: account?.id || '',
9
+ name: account?.name || '',
10
+ currency: account?.currency || null,
11
+ country: account?.country || null,
12
+ owner_username: account?.owner?.username || null,
13
+ time_zone: account?.time_zone || null,
14
+ permissions: Array.isArray(account?.permissions) ? account.permissions : []
15
+ };
16
+ }
17
+
18
+ async function fetchAllPages(request, path, initialParams = {}) {
19
+ const rows = [];
20
+ let bookmark = null;
21
+
22
+ while (true) {
23
+ const pageParams = { ...initialParams };
24
+ if (bookmark) {
25
+ pageParams.bookmark = bookmark;
26
+ }
27
+
28
+ const response = await request(path, pageParams);
29
+ const items = Array.isArray(response?.items) ? response.items : [];
30
+ rows.push(...items);
31
+
32
+ bookmark = response?.bookmark || null;
33
+ if (!bookmark) {
34
+ break;
35
+ }
36
+ }
37
+
38
+ return rows;
39
+ }
40
+
41
+ /**
42
+ * List all accessible Pinterest ad accounts for the authenticated token.
43
+ * @param {{ include_shared_accounts?: boolean }} [params]
44
+ * @param {{ request?: (path: string, params: Record<string, unknown>) => Promise<any> }} [dependencies]
45
+ * @returns {Promise<import('@modelcontextprotocol/sdk/types.js').CallToolResult>}
46
+ */
47
+ export async function listAccounts(params = {}, dependencies = {}) {
48
+ const request = dependencies.request || pinterestRequest;
49
+
50
+ try {
51
+ const queryParams = { page_size: String(PAGE_SIZE) };
52
+ if (params.include_shared_accounts !== undefined) {
53
+ queryParams.include_shared_accounts = String(Boolean(params.include_shared_accounts));
54
+ }
55
+
56
+ const allAccounts = (await fetchAllPages(request, '/ad_accounts', queryParams)).map(mapAccount);
57
+
58
+ return formatSuccess({
59
+ summary: `Found ${allAccounts.length} accessible Pinterest ad account${allAccounts.length === 1 ? '' : 's'}`,
60
+ data: allAccounts,
61
+ metadata: {
62
+ totalAccounts: allAccounts.length
63
+ }
64
+ });
65
+ } catch (error) {
66
+ return formatError(error);
67
+ }
68
+ }
@@ -0,0 +1,134 @@
1
+ import { pinterestRequest } from '../http.js';
2
+ import { formatError, formatSuccess } from '../utils/response-format.js';
3
+ import { invalidParamsError } from '../utils/errors.js';
4
+ import { getAdAccountId } from '../utils/validation.js';
5
+ import {
6
+ buildApiRequest,
7
+ buildRequestPreview,
8
+ validateOperations
9
+ } from '../utils/mutate-operations.js';
10
+
11
+ function extractExceptionMessage(exceptions) {
12
+ if (!Array.isArray(exceptions) || exceptions.length === 0) {
13
+ return null;
14
+ }
15
+
16
+ return exceptions
17
+ .map((exception) => {
18
+ const code = exception?.code ? `[code ${exception.code}] ` : '';
19
+ return `${code}${exception?.message || 'Unknown exception'}`;
20
+ })
21
+ .join('; ');
22
+ }
23
+
24
+ /**
25
+ * Validate and execute Pinterest Ads mutation operations with dry-run safety defaults.
26
+ * Pinterest v5 has no server-side validate-only mode, so dry runs perform local
27
+ * validation and return a preview of the exact requests without calling the API.
28
+ * @param {Record<string, unknown>} [params]
29
+ * @param {{ request?: (path: string, params: Record<string, unknown>, options?: Record<string, unknown>) => Promise<any> }} [dependencies]
30
+ * @returns {Promise<import('@modelcontextprotocol/sdk/types.js').CallToolResult>}
31
+ */
32
+ export async function mutate(params = {}, dependencies = {}) {
33
+ const request = dependencies.request || pinterestRequest;
34
+
35
+ try {
36
+ const operations = params.operations;
37
+ const adAccountId = getAdAccountId(params);
38
+ const partialFailure = params.partial_failure === undefined ? true : Boolean(params.partial_failure);
39
+ const dryRun = params.dry_run === undefined ? true : Boolean(params.dry_run);
40
+
41
+ const validationErrors = validateOperations(operations);
42
+ if (validationErrors.length > 0) {
43
+ const errorMessage = validationErrors.map((issue) => `[${issue.index}] ${issue.message}`).join('; ');
44
+ throw invalidParamsError(`Invalid operations: ${errorMessage}`);
45
+ }
46
+
47
+ if (dryRun) {
48
+ const preview = buildRequestPreview(operations, adAccountId);
49
+
50
+ return formatSuccess({
51
+ summary: `Dry run: ${operations.length} operation(s) validated locally. ${preview.requests.length} request(s) would be sent — Pinterest has no server-side validate-only mode, so no API calls were made. Re-run with dry_run=false to execute.`,
52
+ data: preview.requests,
53
+ metadata: {
54
+ dryRun: true,
55
+ serverValidated: false,
56
+ operationCount: operations.length,
57
+ apiCallCount: preview.requests.length,
58
+ adAccountId
59
+ }
60
+ });
61
+ }
62
+
63
+ if (process.env.PINTEREST_ADS_READ_ONLY === 'true') {
64
+ throw new Error('mutate is disabled in read-only mode (PINTEREST_ADS_READ_ONLY=true)');
65
+ }
66
+
67
+ const results = [];
68
+ let succeeded = 0;
69
+ let failed = 0;
70
+
71
+ for (let index = 0; index < operations.length; index += 1) {
72
+ const operation = operations[index];
73
+ const apiRequest = buildApiRequest(operation, adAccountId);
74
+
75
+ try {
76
+ const response = await request(apiRequest.path, {}, {
77
+ method: apiRequest.method,
78
+ body: apiRequest.body
79
+ });
80
+
81
+ const item = Array.isArray(response?.items) ? response.items[0] : null;
82
+ const exceptionMessage = extractExceptionMessage(item?.exceptions);
83
+ if (exceptionMessage) {
84
+ throw new Error(exceptionMessage);
85
+ }
86
+
87
+ const resolvedId = item?.data?.id || operation.id || null;
88
+ results.push({
89
+ index,
90
+ entity: operation.entity,
91
+ action: operation.action,
92
+ success: true,
93
+ id: resolvedId,
94
+ data: item?.data ?? response
95
+ });
96
+ succeeded += 1;
97
+ } catch (error) {
98
+ results.push({
99
+ index,
100
+ entity: operation.entity,
101
+ action: operation.action,
102
+ success: false,
103
+ error: {
104
+ message: error.message
105
+ }
106
+ });
107
+ failed += 1;
108
+
109
+ if (!partialFailure) {
110
+ break;
111
+ }
112
+ }
113
+ }
114
+
115
+ if (succeeded === 0 && failed > 0) {
116
+ const firstError = results.find((entry) => !entry.success)?.error?.message || 'Unknown';
117
+ throw new Error(`All ${failed} operation(s) failed. First error: ${firstError}`);
118
+ }
119
+
120
+ return formatSuccess({
121
+ summary: `Executed ${results.length} operation(s): ${succeeded} succeeded, ${failed} failed`,
122
+ data: results,
123
+ metadata: {
124
+ dryRun: false,
125
+ operationCount: results.length,
126
+ succeeded,
127
+ failed,
128
+ adAccountId
129
+ }
130
+ });
131
+ } catch (error) {
132
+ return formatError(error);
133
+ }
134
+ }
@@ -0,0 +1,130 @@
1
+ import { pinterestRequest } from '../http.js';
2
+ import { formatError, formatSuccess } from '../utils/response-format.js';
3
+ import { invalidParamsError } from '../utils/errors.js';
4
+ import { getAdAccountId, toIdList, validateEnum } from '../utils/validation.js';
5
+
6
+ const ENTITY_STATUSES = ['ACTIVE', 'PAUSED', 'ARCHIVED', 'DRAFT', 'DELETED_DRAFT'];
7
+ const ORDERS = ['ASCENDING', 'DESCENDING'];
8
+ const MAX_PAGE_SIZE = 250;
9
+
10
+ /**
11
+ * Supported query entities mapped to their id-filter query parameters.
12
+ */
13
+ export const QUERY_ENTITIES = {
14
+ campaigns: ['campaign_ids'],
15
+ ad_groups: ['campaign_ids', 'ad_group_ids'],
16
+ ads: ['campaign_ids', 'ad_group_ids', 'ad_ids']
17
+ };
18
+
19
+ export const SUPPORTED_QUERY_ENTITIES = Object.keys(QUERY_ENTITIES);
20
+
21
+ const ALL_ID_FILTERS = ['campaign_ids', 'ad_group_ids', 'ad_ids'];
22
+
23
+ function getRequestedLimit(limitValue) {
24
+ if (limitValue === undefined || limitValue === null || limitValue === '') {
25
+ return 100;
26
+ }
27
+
28
+ const parsed = Number(limitValue);
29
+ if (!Number.isFinite(parsed) || parsed <= 0) {
30
+ return 100;
31
+ }
32
+
33
+ return Math.min(Math.floor(parsed), 1000);
34
+ }
35
+
36
+ function buildBaseParams(params, entity) {
37
+ const queryParams = {};
38
+ const supportedFilters = QUERY_ENTITIES[entity];
39
+
40
+ for (const filterName of ALL_ID_FILTERS) {
41
+ const ids = toIdList(params[filterName]);
42
+ if (ids.length === 0) {
43
+ continue;
44
+ }
45
+
46
+ if (!supportedFilters.includes(filterName)) {
47
+ throw invalidParamsError(`${filterName} is not supported for entity "${entity}". Supported filters: ${supportedFilters.join(', ')}`);
48
+ }
49
+
50
+ queryParams[filterName] = ids;
51
+ }
52
+
53
+ const entityStatuses = toIdList(params.entity_statuses).map((status) => status.toUpperCase());
54
+ for (const status of entityStatuses) {
55
+ validateEnum(status, ENTITY_STATUSES, 'entity_statuses');
56
+ }
57
+ if (entityStatuses.length > 0) {
58
+ queryParams.entity_statuses = entityStatuses;
59
+ }
60
+
61
+ if (params.order) {
62
+ const order = String(params.order).toUpperCase();
63
+ validateEnum(order, ORDERS, 'order');
64
+ queryParams.order = order;
65
+ }
66
+
67
+ return queryParams;
68
+ }
69
+
70
+ async function fetchAllPages(path, initialParams, requestedLimit, request) {
71
+ let bookmark = null;
72
+ const results = [];
73
+
74
+ while (results.length < requestedLimit) {
75
+ const pageParams = {
76
+ ...initialParams,
77
+ page_size: String(Math.min(requestedLimit - results.length, MAX_PAGE_SIZE))
78
+ };
79
+
80
+ if (bookmark) {
81
+ pageParams.bookmark = bookmark;
82
+ }
83
+
84
+ const response = await request(path, pageParams);
85
+ const items = Array.isArray(response?.items) ? response.items : [];
86
+ results.push(...items);
87
+
88
+ bookmark = response?.bookmark || null;
89
+ if (!bookmark) {
90
+ break;
91
+ }
92
+ }
93
+
94
+ return results.slice(0, requestedLimit);
95
+ }
96
+
97
+ /**
98
+ * Query Pinterest Ads entities (campaigns, ad_groups, ads) with bookmark pagination.
99
+ * @param {Record<string, unknown>} [params]
100
+ * @param {{ request?: (path: string, params: Record<string, unknown>) => Promise<any> }} [dependencies]
101
+ * @returns {Promise<import('@modelcontextprotocol/sdk/types.js').CallToolResult>}
102
+ */
103
+ export async function query(params = {}, dependencies = {}) {
104
+ const request = dependencies.request || pinterestRequest;
105
+
106
+ try {
107
+ validateEnum(params.entity, SUPPORTED_QUERY_ENTITIES, 'entity');
108
+
109
+ const adAccountId = getAdAccountId(params);
110
+ const entity = params.entity;
111
+ const requestedLimit = getRequestedLimit(params.limit);
112
+ const path = `/ad_accounts/${adAccountId}/${entity}`;
113
+
114
+ const queryParams = buildBaseParams(params, entity);
115
+ const rows = await fetchAllPages(path, queryParams, requestedLimit, request);
116
+
117
+ return formatSuccess({
118
+ summary: `Returned ${rows.length} ${entity} row${rows.length === 1 ? '' : 's'}`,
119
+ data: rows,
120
+ metadata: {
121
+ entity,
122
+ adAccountId,
123
+ limit: requestedLimit,
124
+ returned: rows.length
125
+ }
126
+ });
127
+ } catch (error) {
128
+ return formatError(error);
129
+ }
130
+ }
@@ -0,0 +1,106 @@
1
+ import { invalidParamsError } from './errors.js';
2
+ import { toIdList, validateEnum } from './validation.js';
3
+
4
+ /**
5
+ * Default analytics columns returned when the caller does not request specific columns.
6
+ * All names are valid `columns` values on Pinterest v5 analytics endpoints.
7
+ */
8
+ export const DEFAULT_ANALYTICS_COLUMNS = [
9
+ 'SPEND_IN_DOLLAR',
10
+ 'IMPRESSION_2',
11
+ 'CLICKTHROUGH_2',
12
+ 'CTR_2',
13
+ 'TOTAL_CONVERSIONS'
14
+ ];
15
+
16
+ /**
17
+ * Supported analytics granularity values.
18
+ */
19
+ export const GRANULARITIES = ['TOTAL', 'DAY', 'HOUR', 'WEEK', 'MONTH'];
20
+
21
+ /**
22
+ * Supported analytics levels mapped to endpoint paths and required id params.
23
+ */
24
+ export const ANALYTICS_LEVELS = {
25
+ account: { pathSuffix: '/analytics', idsParam: null },
26
+ campaign: { pathSuffix: '/campaigns/analytics', idsParam: 'campaign_ids' },
27
+ ad_group: { pathSuffix: '/ad_groups/analytics', idsParam: 'ad_group_ids' },
28
+ ad: { pathSuffix: '/ads/analytics', idsParam: 'ad_ids' }
29
+ };
30
+
31
+ export const SUPPORTED_ANALYTICS_LEVELS = Object.keys(ANALYTICS_LEVELS);
32
+
33
+ const MAX_LOOKBACK_DAYS = 90;
34
+ const MAX_RANGE_DAYS = 90;
35
+ const DAY_MS = 24 * 60 * 60 * 1000;
36
+
37
+ function parseIsoDate(value, label) {
38
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(String(value || ''))) {
39
+ throw invalidParamsError(`Invalid ${label} date format: ${value}. Expected YYYY-MM-DD`);
40
+ }
41
+
42
+ const parsed = Date.parse(`${value}T00:00:00.000Z`);
43
+ if (!Number.isFinite(parsed)) {
44
+ throw invalidParamsError(`Invalid ${label} date: ${value}`);
45
+ }
46
+
47
+ return parsed;
48
+ }
49
+
50
+ /**
51
+ * Validate an analytics date range against Pinterest API constraints:
52
+ * YYYY-MM-DD format, start_date <= end_date, start_date no more than 90 days
53
+ * back from today, and end_date no more than 90 days past start_date.
54
+ * @param {string} startDate
55
+ * @param {string} endDate
56
+ * @param {Date} [now]
57
+ * @returns {{ startDate: string, endDate: string }}
58
+ */
59
+ export function validateDateRange(startDate, endDate, now = new Date()) {
60
+ const startMs = parseIsoDate(startDate, 'start_date');
61
+ const endMs = parseIsoDate(endDate, 'end_date');
62
+
63
+ if (startMs > endMs) {
64
+ throw invalidParamsError(`start_date (${startDate}) must not be after end_date (${endDate})`);
65
+ }
66
+
67
+ const todayMs = Date.parse(`${now.toISOString().slice(0, 10)}T00:00:00.000Z`);
68
+ if (todayMs - startMs > MAX_LOOKBACK_DAYS * DAY_MS) {
69
+ throw invalidParamsError(`start_date (${startDate}) cannot be more than ${MAX_LOOKBACK_DAYS} days back from today`);
70
+ }
71
+
72
+ if (endMs - startMs > MAX_RANGE_DAYS * DAY_MS) {
73
+ throw invalidParamsError(`Date range exceeds ${MAX_RANGE_DAYS} days: ${startDate} to ${endDate}`);
74
+ }
75
+
76
+ return { startDate, endDate };
77
+ }
78
+
79
+ /**
80
+ * Resolve requested analytics columns to a validated non-empty string array.
81
+ * @param {string | string[] | undefined} columns
82
+ * @returns {string[]}
83
+ */
84
+ export function resolveColumns(columns) {
85
+ const resolved = toIdList(columns);
86
+ if (resolved.length === 0) {
87
+ return [...DEFAULT_ANALYTICS_COLUMNS];
88
+ }
89
+
90
+ return resolved.map((column) => column.toUpperCase());
91
+ }
92
+
93
+ /**
94
+ * Resolve and validate the requested granularity (default TOTAL).
95
+ * @param {string | undefined} granularity
96
+ * @returns {string}
97
+ */
98
+ export function resolveGranularity(granularity) {
99
+ if (granularity === undefined || granularity === null || granularity === '') {
100
+ return 'TOTAL';
101
+ }
102
+
103
+ const normalized = String(granularity).toUpperCase();
104
+ validateEnum(normalized, GRANULARITIES, 'granularity');
105
+ return normalized;
106
+ }
@@ -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
+ }