@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,272 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import {
5
+ CallToolRequestSchema,
6
+ GetPromptRequestSchema,
7
+ ListPromptsRequestSchema,
8
+ ListResourcesRequestSchema,
9
+ ListToolsRequestSchema,
10
+ ReadResourceRequestSchema
11
+ } from '@modelcontextprotocol/sdk/types.js';
12
+
13
+ import { validateEnvironment } from './auth.js';
14
+ import { listAccounts } from './tools/list-accounts.js';
15
+ import { query } from './tools/query.js';
16
+ import { analytics } from './tools/analytics.js';
17
+ import { mutate } from './tools/mutate.js';
18
+ import { getPromptsList, renderPrompt } from './prompts/templates.js';
19
+ import { getResourcesList, readResource } from './resources/index.js';
20
+
21
+ const SERVER_NAME = 'pinterest-ads-mcp';
22
+ const SERVER_VERSION = '0.1.0';
23
+ const READ_ONLY = process.env.PINTEREST_ADS_READ_ONLY === 'true';
24
+
25
+ const IDS_FILTER_SCHEMA = {
26
+ anyOf: [
27
+ { type: 'string' },
28
+ {
29
+ type: 'array',
30
+ items: { type: 'string' }
31
+ }
32
+ ]
33
+ };
34
+
35
+ const ALL_TOOLS = [
36
+ {
37
+ name: 'list_accounts',
38
+ description: 'List all accessible Pinterest ad accounts for the authenticated token, including id, name, currency, country, and owner.',
39
+ inputSchema: {
40
+ type: 'object',
41
+ properties: {
42
+ include_shared_accounts: {
43
+ type: 'boolean',
44
+ description: 'Include ad accounts shared with the authenticated user (API default: true)'
45
+ }
46
+ }
47
+ }
48
+ },
49
+ {
50
+ name: 'query',
51
+ description: 'Query Pinterest Ads campaigns, ad_groups, or ads with optional entity status and id filters. Note: the API returns only ACTIVE and PAUSED entities by default — pass entity_statuses to include ARCHIVED or DRAFT.',
52
+ inputSchema: {
53
+ type: 'object',
54
+ properties: {
55
+ ad_account_id: {
56
+ type: 'string',
57
+ description: 'Pinterest ad account ID (falls back to PINTEREST_ADS_AD_ACCOUNT_ID)'
58
+ },
59
+ entity: {
60
+ type: 'string',
61
+ enum: ['campaigns', 'ad_groups', 'ads'],
62
+ description: 'Entity type to query'
63
+ },
64
+ entity_statuses: {
65
+ ...IDS_FILTER_SCHEMA,
66
+ description: 'Status filter: ACTIVE, PAUSED, ARCHIVED, DRAFT, DELETED_DRAFT (API default: ACTIVE, PAUSED)'
67
+ },
68
+ campaign_ids: {
69
+ ...IDS_FILTER_SCHEMA,
70
+ description: 'Filter by campaign IDs (comma-separated string or array)'
71
+ },
72
+ ad_group_ids: {
73
+ ...IDS_FILTER_SCHEMA,
74
+ description: 'Filter by ad group IDs (ad_groups and ads entities only)'
75
+ },
76
+ ad_ids: {
77
+ ...IDS_FILTER_SCHEMA,
78
+ description: 'Filter by ad IDs (ads entity only)'
79
+ },
80
+ order: {
81
+ type: 'string',
82
+ enum: ['ASCENDING', 'DESCENDING'],
83
+ description: 'Sort order by ID (higher IDs are more recently created)'
84
+ },
85
+ limit: {
86
+ type: 'integer',
87
+ minimum: 1,
88
+ maximum: 1000,
89
+ description: 'Maximum rows to return across pages (default: 100, max: 1000)'
90
+ }
91
+ },
92
+ required: ['entity']
93
+ }
94
+ },
95
+ {
96
+ name: 'analytics',
97
+ description: 'Pull Pinterest Ads analytics at account, campaign, ad_group, or ad level. Entity levels require the matching ids param (campaign_ids, ad_group_ids, ad_ids). Dates are YYYY-MM-DD, at most 90 days back and a 90-day range.',
98
+ inputSchema: {
99
+ type: 'object',
100
+ properties: {
101
+ ad_account_id: {
102
+ type: 'string',
103
+ description: 'Pinterest ad account ID (falls back to PINTEREST_ADS_AD_ACCOUNT_ID)'
104
+ },
105
+ level: {
106
+ type: 'string',
107
+ enum: ['account', 'campaign', 'ad_group', 'ad'],
108
+ description: 'Reporting level (default: account)'
109
+ },
110
+ start_date: {
111
+ type: 'string',
112
+ description: 'Report start date (YYYY-MM-DD, UTC). Cannot be more than 90 days back from today.'
113
+ },
114
+ end_date: {
115
+ type: 'string',
116
+ description: 'Report end date (YYYY-MM-DD, UTC). Cannot be more than 90 days past start_date.'
117
+ },
118
+ columns: {
119
+ ...IDS_FILTER_SCHEMA,
120
+ description: 'Metric columns (default: SPEND_IN_DOLLAR, IMPRESSION_2, CLICKTHROUGH_2, CTR_2, TOTAL_CONVERSIONS). See the pinterestads://analytics-columns resource.'
121
+ },
122
+ granularity: {
123
+ type: 'string',
124
+ enum: ['TOTAL', 'DAY', 'WEEK', 'MONTH', 'HOUR'],
125
+ description: 'Time breakdown (default: TOTAL). HOUR no longer returns conversion metrics.'
126
+ },
127
+ campaign_ids: {
128
+ ...IDS_FILTER_SCHEMA,
129
+ description: 'Campaign IDs (required for level=campaign)'
130
+ },
131
+ ad_group_ids: {
132
+ ...IDS_FILTER_SCHEMA,
133
+ description: 'Ad group IDs (required for level=ad_group)'
134
+ },
135
+ ad_ids: {
136
+ ...IDS_FILTER_SCHEMA,
137
+ description: 'Ad IDs (required for level=ad)'
138
+ },
139
+ click_window_days: {
140
+ type: 'integer',
141
+ enum: [0, 1, 7, 14, 30, 60],
142
+ description: 'Click attribution window in days (API default: 30)'
143
+ },
144
+ engagement_window_days: {
145
+ type: 'integer',
146
+ enum: [0, 1, 7, 14, 30, 60],
147
+ description: 'Engagement attribution window in days (API default: 30)'
148
+ },
149
+ view_window_days: {
150
+ type: 'integer',
151
+ enum: [0, 1, 7, 14, 30, 60],
152
+ description: 'View attribution window in days (API default: 1)'
153
+ },
154
+ conversion_report_time: {
155
+ type: 'string',
156
+ enum: ['TIME_OF_AD_ACTION', 'TIME_OF_CONVERSION'],
157
+ description: 'Report conversions by ad action time or conversion time (API default: TIME_OF_AD_ACTION)'
158
+ },
159
+ reporting_timezone: {
160
+ type: 'string',
161
+ enum: ['PINTEREST_TIME_ZONE', 'AD_ACCOUNT_TIME_ZONE'],
162
+ description: 'Timezone used to bucket metrics'
163
+ }
164
+ },
165
+ required: ['start_date', 'end_date']
166
+ }
167
+ },
168
+ {
169
+ name: 'mutate',
170
+ description: 'Execute create/update/pause/enable/archive operations for campaign, ad_group, and ad entities via Pinterest bulk-array endpoints. dry_run defaults to true and performs local validation plus a preview of the exact requests (Pinterest has no server-side validate-only mode). Creates default to PAUSED status. Pinterest has NO delete — archive (status ARCHIVED) is the terminal state and archived entities cannot be reactivated. Campaign creates require name and objective_type; ad_group creates require name, campaign_id, and billable_event; ad creates require ad_group_id, pin_id, and creative_type.',
171
+ inputSchema: {
172
+ type: 'object',
173
+ properties: {
174
+ ad_account_id: {
175
+ type: 'string',
176
+ description: 'Pinterest ad account ID (falls back to PINTEREST_ADS_AD_ACCOUNT_ID)'
177
+ },
178
+ operations: {
179
+ type: 'array',
180
+ description: 'Array of operation objects: { entity, action, id?, params? }',
181
+ items: {
182
+ type: 'object'
183
+ }
184
+ },
185
+ dry_run: {
186
+ type: 'boolean',
187
+ description: 'Validate and preview only (default: true)',
188
+ default: true
189
+ },
190
+ partial_failure: {
191
+ type: 'boolean',
192
+ description: 'Continue executing later operations when one fails (default: true)',
193
+ default: true
194
+ }
195
+ },
196
+ required: ['operations']
197
+ }
198
+ }
199
+ ];
200
+
201
+ const TOOLS = READ_ONLY ? ALL_TOOLS.filter((tool) => tool.name !== 'mutate') : ALL_TOOLS;
202
+
203
+ const envStatus = validateEnvironment();
204
+ if (!envStatus.valid) {
205
+ console.error(`Missing required environment variables: ${envStatus.missing.join(', ')}`);
206
+ console.error('Set PINTEREST_ADS_ACCESS_TOKEN, or all of PINTEREST_ADS_CLIENT_ID + PINTEREST_ADS_CLIENT_SECRET + PINTEREST_ADS_REFRESH_TOKEN for the refresh-token flow.');
207
+ process.exit(1);
208
+ }
209
+
210
+ if (READ_ONLY) {
211
+ console.error('Read-only mode enabled — mutate tool disabled');
212
+ }
213
+
214
+ const server = new Server(
215
+ {
216
+ name: SERVER_NAME,
217
+ version: SERVER_VERSION
218
+ },
219
+ {
220
+ capabilities: {
221
+ tools: {},
222
+ prompts: {},
223
+ resources: {}
224
+ }
225
+ }
226
+ );
227
+
228
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
229
+
230
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
231
+ const { name, arguments: params } = request.params;
232
+
233
+ if (name === 'list_accounts') {
234
+ return listAccounts(params);
235
+ }
236
+
237
+ if (name === 'query') {
238
+ return query(params);
239
+ }
240
+
241
+ if (name === 'analytics') {
242
+ return analytics(params);
243
+ }
244
+
245
+ if (name === 'mutate') {
246
+ if (READ_ONLY) {
247
+ throw new Error('mutate is disabled in read-only mode (PINTEREST_ADS_READ_ONLY=true)');
248
+ }
249
+ return mutate(params);
250
+ }
251
+
252
+ throw new Error(`Unknown tool: ${name}`);
253
+ });
254
+
255
+ server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: getPromptsList() }));
256
+ server.setRequestHandler(GetPromptRequestSchema, async (request) => (
257
+ renderPrompt(request.params.name, request.params.arguments || {})
258
+ ));
259
+
260
+ server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: getResourcesList() }));
261
+ server.setRequestHandler(ReadResourceRequestSchema, async (request) => readResource(request.params.uri));
262
+
263
+ async function main() {
264
+ const transport = new StdioServerTransport();
265
+ await server.connect(transport);
266
+ console.error(`${SERVER_NAME} v${SERVER_VERSION} started`);
267
+ }
268
+
269
+ main().catch((error) => {
270
+ console.error('Fatal error:', error);
271
+ process.exit(1);
272
+ });
@@ -0,0 +1,130 @@
1
+ export const PROMPT_TEMPLATES = {
2
+ spend_pacing_check: {
3
+ name: 'spend_pacing_check',
4
+ description: 'Check campaign spend pacing against budgets and recent delivery',
5
+ arguments: [
6
+ {
7
+ name: 'ad_account_id',
8
+ description: 'Pinterest ad account ID',
9
+ required: true
10
+ },
11
+ {
12
+ name: 'start_date',
13
+ description: 'Report start date (YYYY-MM-DD, within the last 90 days)',
14
+ required: true
15
+ },
16
+ {
17
+ name: 'end_date',
18
+ description: 'Report end date (YYYY-MM-DD)',
19
+ required: true
20
+ }
21
+ ],
22
+ template: `Run a spend pacing check for Pinterest ad account {{ad_account_id}} from {{start_date}} to {{end_date}}:
23
+
24
+ 1. Query active campaigns with their daily_spend_cap and lifetime_spend_cap values.
25
+ 2. Pull campaign-level analytics (SPEND_IN_DOLLAR, IMPRESSION_2, CLICKTHROUGH_2, TOTAL_CONVERSIONS) with DAY granularity.
26
+ 3. Compare daily spend velocity against spend caps (caps are in micro-currency: 1,000,000 = 1 unit).
27
+ 4. Flag campaigns underpacing (spend well below cap) and overpacing (near or at cap early in the day).
28
+ 5. Highlight campaigns spending with weak conversion signals.
29
+ 6. Return top pacing issues with recommended budget actions.`,
30
+ requiredTools: ['query', 'analytics']
31
+ },
32
+ creative_performance_review: {
33
+ name: 'creative_performance_review',
34
+ description: 'Review ad-level performance and flag fatigued or underperforming Pins',
35
+ arguments: [
36
+ {
37
+ name: 'ad_account_id',
38
+ description: 'Pinterest ad account ID',
39
+ required: true
40
+ },
41
+ {
42
+ name: 'start_date',
43
+ description: 'Report start date (YYYY-MM-DD, within the last 90 days)',
44
+ required: true
45
+ },
46
+ {
47
+ name: 'end_date',
48
+ description: 'Report end date (YYYY-MM-DD)',
49
+ required: true
50
+ }
51
+ ],
52
+ template: `Run a creative performance review for Pinterest ad account {{ad_account_id}} from {{start_date}} to {{end_date}}:
53
+
54
+ 1. Query active ads to collect ad IDs, names, creative types, and parent ad groups.
55
+ 2. Pull ad-level analytics (SPEND_IN_DOLLAR, IMPRESSION_2, CLICKTHROUGH_2, CTR_2, TOTAL_ENGAGEMENT, TOTAL_CONVERSIONS) with WEEK granularity.
56
+ 3. Flag ads with declining week-over-week CTR or engagement despite sustained impressions.
57
+ 4. Group weak performers by ad group and campaign to spot structural issues.
58
+ 5. Rank creatives to refresh first by wasted spend.
59
+ 6. Return prioritized creative refresh recommendations with rationale.`,
60
+ requiredTools: ['query', 'analytics']
61
+ },
62
+ account_structure_audit: {
63
+ name: 'account_structure_audit',
64
+ description: 'Audit campaign and ad group structure for objective and budget hygiene',
65
+ arguments: [
66
+ {
67
+ name: 'ad_account_id',
68
+ description: 'Pinterest ad account ID',
69
+ required: true
70
+ }
71
+ ],
72
+ template: `Run an account structure audit for Pinterest ad account {{ad_account_id}}:
73
+
74
+ 1. Query all campaigns (include ACTIVE and PAUSED) with objective_type, spend caps, and status.
75
+ 2. Query ad groups with budgets, bid strategies, billable events, and targeting summaries.
76
+ 3. Flag campaigns with mismatched objective_type and ad group billable_event combinations.
77
+ 4. Identify ad groups without budgets, with overlapping targeting, or stuck in DRAFT status.
78
+ 5. Check for single-ad ad groups that limit creative rotation.
79
+ 6. Return a prioritized list of structural fixes with expected impact.`,
80
+ requiredTools: ['query']
81
+ }
82
+ };
83
+
84
+ export function getPromptDefinition(name) {
85
+ return PROMPT_TEMPLATES[name] || null;
86
+ }
87
+
88
+ export function getPromptsList() {
89
+ return Object.values(PROMPT_TEMPLATES).map((prompt) => ({
90
+ name: prompt.name,
91
+ description: prompt.description,
92
+ arguments: prompt.arguments
93
+ }));
94
+ }
95
+
96
+ export function renderPrompt(name, args = {}) {
97
+ const template = PROMPT_TEMPLATES[name];
98
+ if (!template) {
99
+ throw new Error(`Unknown prompt: ${name}. Available prompts: ${Object.keys(PROMPT_TEMPLATES).join(', ')}`);
100
+ }
101
+
102
+ const missing = template.arguments
103
+ .filter((argument) => argument.required && !args[argument.name])
104
+ .map((argument) => argument.name);
105
+
106
+ if (missing.length > 0) {
107
+ throw new Error(`Missing required arguments for prompt "${name}": ${missing.join(', ')}`);
108
+ }
109
+
110
+ let text = template.template;
111
+
112
+ for (const argument of template.arguments) {
113
+ const value = args[argument.name] ?? argument.default;
114
+ if (value !== undefined) {
115
+ text = text.replace(new RegExp(`\\{\\{${argument.name}\\}\\}`, 'g'), String(value));
116
+ }
117
+ }
118
+
119
+ return {
120
+ messages: [
121
+ {
122
+ role: 'user',
123
+ content: {
124
+ type: 'text',
125
+ text
126
+ }
127
+ }
128
+ ]
129
+ };
130
+ }
@@ -0,0 +1,86 @@
1
+ # Pinterest Ads Analytics Columns Reference
2
+
3
+ Analytics endpoints require a `columns` list. Pinterest exposes ~185 column names;
4
+ this document covers the ones most useful for marketer workflows. All names below
5
+ are valid values on the v5 analytics endpoints.
6
+
7
+ ## Naming Conventions
8
+
9
+ - `_1` suffix: paid-only metric (attributed to the ad impression itself)
10
+ - `_2` suffix: paid + earned metric (includes downstream organic activity such as saves/repins of the promoted Pin)
11
+ - `TOTAL_` prefix: conversion totals across attribution types (click + engagement + view)
12
+ - `*_IN_MICRO_DOLLAR`: value in micro-currency of the ad account's currency (1,000,000 = 1 unit)
13
+ - `*_IN_DOLLAR`: value in whole currency units of the ad account's currency
14
+
15
+ ## Default Columns Used by This Server
16
+
17
+ | Column | Meaning |
18
+ |--------|---------|
19
+ | `SPEND_IN_DOLLAR` | Spend in currency units |
20
+ | `IMPRESSION_2` | Paid + earned impressions |
21
+ | `CLICKTHROUGH_2` | Paid + earned Pin clicks |
22
+ | `CTR_2` | Paid + earned click-through rate |
23
+ | `TOTAL_CONVERSIONS` | Total attributed conversions |
24
+
25
+ ## Common Spend and Delivery Columns
26
+
27
+ - `SPEND_IN_MICRO_DOLLAR`, `SPEND_IN_DOLLAR`
28
+ - `IMPRESSION_1`, `IMPRESSION_2`, `PAID_IMPRESSION`, `TOTAL_IMPRESSION`
29
+ - `TOTAL_IMPRESSION_USER` (reach), `TOTAL_IMPRESSION_FREQUENCY` (frequency)
30
+ - `CLICKTHROUGH_1`, `CLICKTHROUGH_2`, `TOTAL_CLICKTHROUGH`
31
+ - `OUTBOUND_CLICK_1`, `OUTBOUND_CLICK_2`, `OUTBOUND_CTR_1`
32
+
33
+ ## Efficiency Columns
34
+
35
+ - `CTR`, `CTR_2`, `ECTR`
36
+ - `CPC_IN_MICRO_DOLLAR`, `ECPC_IN_DOLLAR`, `ECPC_IN_MICRO_DOLLAR`
37
+ - `CPM_IN_DOLLAR`, `CPM_IN_MICRO_DOLLAR`, `ECPM_IN_MICRO_DOLLAR`
38
+ - `ECPE_IN_DOLLAR` (cost per engagement), `COST_PER_LEAD`
39
+ - `COST_PER_OUTBOUND_CLICK_IN_DOLLAR`
40
+
41
+ ## Engagement Columns
42
+
43
+ - `ENGAGEMENT_1`, `ENGAGEMENT_2`, `TOTAL_ENGAGEMENT`
44
+ - `ENGAGEMENT_RATE`, `EENGAGEMENT_RATE`
45
+ - `REPIN_1`, `REPIN_2`, `REPIN_RATE` (saves)
46
+
47
+ ## Conversion Columns
48
+
49
+ - `TOTAL_CONVERSIONS`
50
+ - `TOTAL_CHECKOUT`, `TOTAL_CHECKOUT_VALUE_IN_MICRO_DOLLAR`, `CHECKOUT_ROAS`
51
+ - `TOTAL_CLICK_CHECKOUT`, `TOTAL_VIEW_CHECKOUT`, `TOTAL_ENGAGEMENT_CHECKOUT`
52
+ - `TOTAL_SIGNUP`, `TOTAL_SIGNUP_VALUE_IN_MICRO_DOLLAR`
53
+ - `TOTAL_LEAD`, `LEADS`, `TOTAL_PAGE_VISIT`, `PAGE_VISIT_ROAS`
54
+ - `TOTAL_ADD_TO_CART_CONVERSION_RATE`, `TOTAL_CHECKOUT_CONVERSION_RATE`
55
+ - `TOTAL_WEB_SESSIONS`, `WEB_SESSIONS_1`, `WEB_SESSIONS_2`
56
+
57
+ ## Video Columns
58
+
59
+ - `TOTAL_VIDEO_3SEC_VIEWS`, `TOTAL_VIDEO_MRC_VIEWS`
60
+ - `TOTAL_VIDEO_P25_COMBINED` through `TOTAL_VIDEO_P95_COMBINED`, `TOTAL_VIDEO_P100_COMPLETE`
61
+ - `TOTAL_VIDEO_AVG_WATCHTIME_IN_SECOND`, `VIDEO_LENGTH`
62
+ - `ECPV_IN_DOLLAR` (cost per view), `ECPCV_IN_DOLLAR` (cost per completed view)
63
+ - `PAID_VIDEO_VIEWABLE_RATE`, `VIDEO_SPEND_IN_DOLLAR`
64
+
65
+ ## Dimension / Metadata Columns
66
+
67
+ Useful for labeling rows in entity-level reports:
68
+
69
+ - `CAMPAIGN_ID`, `CAMPAIGN_NAME`, `CAMPAIGN_ENTITY_STATUS`, `CAMPAIGN_OBJECTIVE_TYPE`
70
+ - `CAMPAIGN_DAILY_SPEND_CAP`, `CAMPAIGN_LIFETIME_SPEND_CAP`
71
+ - `AD_GROUP_ID`, `AD_GROUP_NAME`, `AD_GROUP_ENTITY_STATUS`, `AD_GROUP_BUDGET_TYPE`
72
+ - `AD_ID`, `AD_NAME`, `PIN_ID`, `PIN_PROMOTION_ID`
73
+
74
+ ## Attribution Windows
75
+
76
+ Conversion metrics respect the requested attribution windows:
77
+
78
+ - `click_window_days` (default 30), `engagement_window_days` (default 30), `view_window_days` (default 1)
79
+ - Each accepts `0, 1, 7, 14, 30, 60`
80
+ - `conversion_report_time` controls whether conversions are bucketed by ad action time (default) or conversion time
81
+
82
+ ## Granularity Notes
83
+
84
+ - `TOTAL` aggregates the full range into one row; other granularities add a `DATE` field per row
85
+ - `HOUR` granularity no longer returns conversion metrics (non-conversion metrics still work)
86
+ - Dates are limited to 90 days back and a 90-day range
@@ -0,0 +1,100 @@
1
+ # Pinterest Ads REST API v5 Quick Reference
2
+
3
+ ## Base URL
4
+
5
+ `https://api.pinterest.com/v5`
6
+
7
+ ## Authentication
8
+
9
+ - Auth is sent via `Authorization: Bearer <TOKEN>` request header.
10
+ - This server reads a static token from `PINTEREST_ADS_ACCESS_TOKEN`, or refreshes one
11
+ when `PINTEREST_ADS_CLIENT_ID`, `PINTEREST_ADS_CLIENT_SECRET`, and
12
+ `PINTEREST_ADS_REFRESH_TOKEN` are all set.
13
+ - Token refresh: `POST /oauth/token` with `Authorization: Basic base64(client_id:client_secret)`
14
+ and form body `grant_type=refresh_token&refresh_token=<TOKEN>`.
15
+ - Pinterest uses continuous refresh tokens (60-day expiration window). Refresh responses
16
+ may include a rotated `refresh_token`; this server logs a stderr warning when that
17
+ happens because it cannot persist the new value itself.
18
+ - Required scopes: `ads:read` for read tools, `ads:write` for mutate.
19
+
20
+ ## Pagination
21
+
22
+ All list endpoints use bookmark cursors:
23
+
24
+ - Request params: `page_size` (default 25, max 250) and `bookmark`
25
+ - Response envelope: `{ "items": [...], "bookmark": "<cursor>" | null }`
26
+ - A `null`/absent bookmark means the last page was reached
27
+
28
+ ## Account Discovery
29
+
30
+ - List accounts: `GET /ad_accounts`
31
+ - Optional param: `include_shared_accounts` (default true)
32
+ - Account fields include: `id`, `name`, `currency`, `country`, `owner{id,username}`, `time_zone`, `permissions`
33
+
34
+ ## Entity Endpoints (by Ad Account)
35
+
36
+ - Campaigns: `GET /ad_accounts/<AD_ACCOUNT_ID>/campaigns`
37
+ - Ad groups: `GET /ad_accounts/<AD_ACCOUNT_ID>/ad_groups`
38
+ - Ads: `GET /ad_accounts/<AD_ACCOUNT_ID>/ads`
39
+
40
+ Useful query params (arrays are comma-separated strings):
41
+
42
+ - `entity_statuses`: `ACTIVE`, `PAUSED`, `ARCHIVED`, `DRAFT`, `DELETED_DRAFT` (API default: `ACTIVE,PAUSED`)
43
+ - `campaign_ids`: all three endpoints
44
+ - `ad_group_ids`: ad_groups and ads endpoints
45
+ - `ad_ids`: ads endpoint only
46
+ - `order`: `ASCENDING` or `DESCENDING` by ID (higher IDs are more recent)
47
+
48
+ ## Analytics Endpoints
49
+
50
+ - Account: `GET /ad_accounts/<ID>/analytics`
51
+ - Campaigns: `GET /ad_accounts/<ID>/campaigns/analytics` (requires `campaign_ids`)
52
+ - Ad groups: `GET /ad_accounts/<ID>/ad_groups/analytics` (requires `ad_group_ids`)
53
+ - Ads: `GET /ad_accounts/<ID>/ads/analytics` (requires `ad_ids`)
54
+
55
+ Required params: `start_date`, `end_date` (YYYY-MM-DD, UTC), `columns` (comma-separated), `granularity` (`TOTAL`, `DAY`, `HOUR`, `WEEK`, `MONTH`).
56
+
57
+ Constraints:
58
+
59
+ - `start_date` cannot be more than 90 days back from today
60
+ - `end_date` cannot be more than 90 days past `start_date`
61
+ - `HOUR` granularity no longer returns conversion metrics
62
+
63
+ Optional params: `click_window_days`, `engagement_window_days`, `view_window_days`
64
+ (each one of `0, 1, 7, 14, 30, 60`), `conversion_report_time`
65
+ (`TIME_OF_AD_ACTION` | `TIME_OF_CONVERSION`), `reporting_timezone`
66
+ (`PINTEREST_TIME_ZONE` | `AD_ACCOUNT_TIME_ZONE`).
67
+
68
+ The response is a JSON array of row objects keyed by column name (plus `DATE` for
69
+ non-TOTAL granularities).
70
+
71
+ ## Mutations
72
+
73
+ Pinterest v5 uses bulk-array request bodies (max 30 items per request):
74
+
75
+ - Create: `POST /ad_accounts/<ID>/campaigns` (or `/ad_groups`, `/ads`) with an array of creation objects
76
+ - Update: `PATCH` on the same paths with an array of `{ "id": "...", ...changes }`
77
+ - Status change: `PATCH` with `status` set to `ACTIVE`, `PAUSED`, or `ARCHIVED`
78
+ - Response envelope: `{ "items": [{ "data": {...}, "exceptions": [...] }] }` — check `exceptions` per item
79
+
80
+ Required create fields:
81
+
82
+ - Campaign: `name`, `objective_type` (`AWARENESS`, `CONSIDERATION`, `WEB_CONVERSION`, `CATALOG_SALES`, `VIDEO_COMPLETION`, `APP_INSTALL`, `SALES`, `LEADS`, `CTV_CONSIDERATION`)
83
+ - Ad group: `name`, `campaign_id`, `billable_event`
84
+ - Ad: `ad_group_id`, `pin_id`, `creative_type`
85
+
86
+ Notes:
87
+
88
+ - There is NO delete for campaigns, ad groups, or ads — `ARCHIVED` is the terminal state
89
+ - Archived entities cannot be reactivated
90
+ - Budget and spend cap fields are in micro-currency (1,000,000 = 1 currency unit)
91
+ - `objective_type` can only be updated on draft campaigns
92
+
93
+ ## Error Handling
94
+
95
+ Errors are JSON `{ "code": <int>, "message": "<text>" }` with an HTTP status
96
+ (400, 401, 403, 404, 429). This server:
97
+
98
+ - Surfaces errors as `Pinterest Ads API request failed (<status>): <message>`
99
+ - Retries once on HTTP `429`, honoring `Retry-After` when present (fallback: 60s)
100
+ - Does not retry `401` (invalid/expired token)
@@ -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: 'pinterestads://reference',
14
+ name: 'Pinterest Ads API Reference',
15
+ description: 'Core REST API v5 endpoints, query parameters, pagination, and error handling notes for Pinterest Ads workflows',
16
+ mimeType: 'text/markdown'
17
+ },
18
+ {
19
+ uri: 'pinterestads://analytics-columns',
20
+ name: 'Pinterest Ads Analytics Columns Reference',
21
+ description: 'Common analytics column names, attribution window semantics, and micro-currency conventions',
22
+ mimeType: 'text/markdown'
23
+ },
24
+ {
25
+ uri: 'pinterestads://rate-limits',
26
+ name: 'Pinterest 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
+ 'pinterestads://reference': 'api-reference.md',
34
+ 'pinterestads://analytics-columns': 'analytics-columns-reference.md',
35
+ 'pinterestads://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,39 @@
1
+ # Pinterest Ads Rate Limits and Quotas
2
+
3
+ Pinterest enforces per-app, per-user rate limits on REST API v5. Limits are grouped
4
+ by endpoint category (for example ads read vs. ads write) and depend on your app's
5
+ access level — Trial access has much lower limits than Standard access. Exact
6
+ per-category numbers are published at
7
+ https://developers.pinterest.com/docs/reference/ratelimits/ and can change; treat
8
+ HTTP `429` as the authoritative throttling signal.
9
+
10
+ ## What This MCP Server Does
11
+
12
+ - Treats HTTP `429` as a throttling signal.
13
+ - Reads the `Retry-After` response header when available.
14
+ - Falls back to `60` seconds if `Retry-After` is missing or invalid.
15
+ - Retries throttled requests once.
16
+
17
+ ## Backoff Behavior
18
+
19
+ `Retry-After` values may be:
20
+
21
+ - Integer seconds (for example `7`)
22
+ - HTTP date string
23
+
24
+ The server converts this to milliseconds and sleeps before a single retry.
25
+
26
+ ## Timeout Behavior
27
+
28
+ - Requests use a default timeout of `30000` ms.
29
+ - Override with `PINTEREST_ADS_REQUEST_TIMEOUT_MS` environment variable.
30
+ - Timeout errors are surfaced as request failures so callers can retry or degrade gracefully.
31
+
32
+ ## Operational Guidance
33
+
34
+ - Use `page_size` up to 250 to minimize pagination round trips.
35
+ - Set `limit` deliberately on `query` — the server stops paginating once satisfied.
36
+ - Batch analytics pulls by passing multiple ids (`campaign_ids`, `ad_group_ids`, `ad_ids`) per call instead of one call per entity.
37
+ - Keep `columns` narrow; wide column sets are slower to compute.
38
+ - Stagger high-volume jobs across accounts to reduce burst pressure.
39
+ - Token refreshes count against OAuth limits — this server caches the access token until ~5 minutes before expiry.