@channel47/linkedin-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,254 @@
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 = 'linkedin-ads-mcp';
22
+ const SERVER_VERSION = '0.1.0';
23
+ const READ_ONLY = process.env.LINKEDIN_ADS_READ_ONLY === 'true';
24
+
25
+ const ALL_TOOLS = [
26
+ {
27
+ name: 'list_accounts',
28
+ description: 'List all accessible LinkedIn ad accounts for the authenticated token, with optional status/type filters.',
29
+ inputSchema: {
30
+ type: 'object',
31
+ properties: {
32
+ status: {
33
+ anyOf: [
34
+ { type: 'string' },
35
+ {
36
+ type: 'array',
37
+ items: { type: 'string' }
38
+ }
39
+ ],
40
+ description: 'Optional account status filter (ACTIVE, CANCELED, DRAFT, PENDING_DELETION, REMOVED). String or array.'
41
+ },
42
+ type: {
43
+ anyOf: [
44
+ { type: 'string' },
45
+ {
46
+ type: 'array',
47
+ items: { type: 'string' }
48
+ }
49
+ ],
50
+ description: 'Optional account type filter (BUSINESS, ENTERPRISE). String or array.'
51
+ },
52
+ limit: {
53
+ type: 'integer',
54
+ minimum: 1,
55
+ description: 'Maximum accounts to return (default: 1000)'
56
+ }
57
+ }
58
+ }
59
+ },
60
+ {
61
+ name: 'query',
62
+ description: 'Query campaigns, campaign groups, or creatives for a LinkedIn ad account. Supports either the numeric account ID or the urn:li:sponsoredAccount URN.',
63
+ inputSchema: {
64
+ type: 'object',
65
+ properties: {
66
+ account_id: {
67
+ type: 'string',
68
+ description: 'LinkedIn ad account ID. Supports either 123... or urn:li:sponsoredAccount:123...'
69
+ },
70
+ entity: {
71
+ type: 'string',
72
+ enum: ['campaigns', 'campaign_groups', 'creatives'],
73
+ description: 'Entity type to query'
74
+ },
75
+ status: {
76
+ anyOf: [
77
+ { type: 'string' },
78
+ {
79
+ type: 'array',
80
+ items: { type: 'string' }
81
+ }
82
+ ],
83
+ description: 'Optional status filter (e.g. ACTIVE, PAUSED, DRAFT, ARCHIVED). For creatives this filters intendedStatus.'
84
+ },
85
+ campaign_ids: {
86
+ type: 'array',
87
+ items: { type: 'string' },
88
+ description: 'Creatives only: restrict to these campaign IDs (plain IDs or URNs)'
89
+ },
90
+ limit: {
91
+ type: 'integer',
92
+ minimum: 1,
93
+ description: 'Maximum rows to return (default: 100)'
94
+ }
95
+ },
96
+ required: ['entity']
97
+ }
98
+ },
99
+ {
100
+ name: 'analytics',
101
+ description: 'Fetch adAnalytics metrics pivoted by ACCOUNT, CAMPAIGN_GROUP, CAMPAIGN, or CREATIVE over a date range. Defaults to impressions, clicks, costInLocalCurrency, externalWebsiteConversions plus dateRange/pivotValues; at most 20 metric fields per call. No pagination (LinkedIn caps responses at 15,000 elements).',
102
+ inputSchema: {
103
+ type: 'object',
104
+ properties: {
105
+ account_id: {
106
+ type: 'string',
107
+ description: 'LinkedIn ad account ID used when entity_ids is omitted. Supports either 123... or urn:li:sponsoredAccount:123...'
108
+ },
109
+ pivot: {
110
+ type: 'string',
111
+ enum: ['ACCOUNT', 'CAMPAIGN_GROUP', 'CAMPAIGN', 'CREATIVE'],
112
+ description: 'Dimension to group results by'
113
+ },
114
+ start: {
115
+ type: 'string',
116
+ description: 'Start date (YYYY-MM-DD)'
117
+ },
118
+ end: {
119
+ type: 'string',
120
+ description: 'End date (YYYY-MM-DD). Omit for "through today".'
121
+ },
122
+ time_granularity: {
123
+ type: 'string',
124
+ enum: ['ALL', 'DAILY', 'MONTHLY'],
125
+ description: 'Time breakdown (default: ALL)'
126
+ },
127
+ entity_type: {
128
+ type: 'string',
129
+ enum: ['account', 'campaign_group', 'campaign', 'creative'],
130
+ description: 'Entity type of entity_ids (default: account)'
131
+ },
132
+ entity_ids: {
133
+ type: 'array',
134
+ items: { type: 'string' },
135
+ description: 'Plain IDs (or URNs) to scope the report to. Defaults to the account when omitted.'
136
+ },
137
+ fields: {
138
+ anyOf: [
139
+ { type: 'string' },
140
+ {
141
+ type: 'array',
142
+ items: { type: 'string' }
143
+ }
144
+ ],
145
+ description: 'Metric fields to request (max 20 metrics). Defaults to impressions,clicks,costInLocalCurrency,externalWebsiteConversions,dateRange,pivotValues.'
146
+ }
147
+ },
148
+ required: ['pivot', 'start']
149
+ }
150
+ },
151
+ {
152
+ name: 'mutate',
153
+ description: 'Execute create/update/pause/enable/archive operations for campaign, campaign_group, and creative entities. dry_run defaults to true and returns a local-validation preview of the exact requests without calling the API (LinkedIn has no server-side validate-only mode). Creates default to DRAFT status (LinkedIn\'s safe non-serving state; pass explicit status — intendedStatus for creatives — to override). archive sets status ARCHIVED and is hard to reverse — prefer pause. Updates use Rest.li PARTIAL_UPDATE with { patch: { $set: {...} } }.',
154
+ inputSchema: {
155
+ type: 'object',
156
+ properties: {
157
+ account_id: {
158
+ type: 'string',
159
+ description: 'LinkedIn ad account ID. Supports either 123... or urn:li:sponsoredAccount:123...'
160
+ },
161
+ operations: {
162
+ type: 'array',
163
+ description: 'Array of operation objects: { entity, action, id?, params? }',
164
+ items: {
165
+ type: 'object'
166
+ }
167
+ },
168
+ dry_run: {
169
+ type: 'boolean',
170
+ description: 'Validate locally and preview requests only (default: true)',
171
+ default: true
172
+ },
173
+ partial_failure: {
174
+ type: 'boolean',
175
+ description: 'Continue executing later operations when one fails (default: true)',
176
+ default: true
177
+ }
178
+ },
179
+ required: ['operations']
180
+ }
181
+ }
182
+ ];
183
+
184
+ const TOOLS = READ_ONLY ? ALL_TOOLS.filter((tool) => tool.name !== 'mutate') : ALL_TOOLS;
185
+
186
+ const envStatus = validateEnvironment();
187
+ if (!envStatus.valid) {
188
+ console.error(`Missing required environment variables: ${envStatus.missing.join(', ')}`);
189
+ process.exit(1);
190
+ }
191
+
192
+ if (READ_ONLY) {
193
+ console.error('Read-only mode enabled — mutate tool disabled');
194
+ }
195
+
196
+ const server = new Server(
197
+ {
198
+ name: SERVER_NAME,
199
+ version: SERVER_VERSION
200
+ },
201
+ {
202
+ capabilities: {
203
+ tools: {},
204
+ prompts: {},
205
+ resources: {}
206
+ }
207
+ }
208
+ );
209
+
210
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
211
+
212
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
213
+ const { name, arguments: params } = request.params;
214
+
215
+ if (name === 'list_accounts') {
216
+ return listAccounts(params);
217
+ }
218
+
219
+ if (name === 'query') {
220
+ return query(params);
221
+ }
222
+
223
+ if (name === 'analytics') {
224
+ return analytics(params);
225
+ }
226
+
227
+ if (name === 'mutate') {
228
+ if (READ_ONLY) {
229
+ throw new Error('mutate is disabled in read-only mode (LINKEDIN_ADS_READ_ONLY=true)');
230
+ }
231
+ return mutate(params);
232
+ }
233
+
234
+ throw new Error(`Unknown tool: ${name}`);
235
+ });
236
+
237
+ server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: getPromptsList() }));
238
+ server.setRequestHandler(GetPromptRequestSchema, async (request) => (
239
+ renderPrompt(request.params.name, request.params.arguments || {})
240
+ ));
241
+
242
+ server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: getResourcesList() }));
243
+ server.setRequestHandler(ReadResourceRequestSchema, async (request) => readResource(request.params.uri));
244
+
245
+ async function main() {
246
+ const transport = new StdioServerTransport();
247
+ await server.connect(transport);
248
+ console.error(`${SERVER_NAME} v${SERVER_VERSION} started`);
249
+ }
250
+
251
+ main().catch((error) => {
252
+ console.error('Fatal error:', error);
253
+ process.exit(1);
254
+ });
@@ -0,0 +1,134 @@
1
+ export const PROMPT_TEMPLATES = {
2
+ spend_pacing_check: {
3
+ name: 'spend_pacing_check',
4
+ description: 'Check campaign spend pacing against budgets and run schedules',
5
+ arguments: [
6
+ {
7
+ name: 'account_id',
8
+ description: 'LinkedIn ad account ID (numeric, without URN prefix)',
9
+ required: true
10
+ },
11
+ {
12
+ name: 'start',
13
+ description: 'Analytics start date (YYYY-MM-DD)',
14
+ required: true
15
+ },
16
+ {
17
+ name: 'end',
18
+ description: 'Analytics end date (YYYY-MM-DD, defaults to today)',
19
+ required: false,
20
+ default: 'today'
21
+ }
22
+ ],
23
+ template: `Run a spend pacing check for LinkedIn ad account {{account_id}} from {{start}} to {{end}}:
24
+
25
+ 1. Pull active campaigns with budget and run schedule fields (query, entity=campaigns, status=ACTIVE).
26
+ 2. Pull daily spend per campaign (analytics, pivot=CAMPAIGN, time_granularity=DAILY, fields including costInLocalCurrency).
27
+ 3. Compare spend velocity to dailyBudget/totalBudget and remaining flight time.
28
+ 4. Flag campaigns underpacing and overpacing with projected end-of-flight totals.
29
+ 5. Return top pacing issues and recommended budget or schedule actions.`,
30
+ requiredTools: ['query', 'analytics']
31
+ },
32
+ creative_fatigue_check: {
33
+ name: 'creative_fatigue_check',
34
+ description: 'Check for creative fatigue signals and propose refresh priorities',
35
+ arguments: [
36
+ {
37
+ name: 'account_id',
38
+ description: 'LinkedIn ad account ID (numeric, without URN prefix)',
39
+ required: true
40
+ },
41
+ {
42
+ name: 'start',
43
+ description: 'Analytics start date (YYYY-MM-DD)',
44
+ required: true
45
+ }
46
+ ],
47
+ template: `Run a creative fatigue analysis for LinkedIn ad account {{account_id}} starting {{start}}:
48
+
49
+ 1. Pull active campaigns (query, entity=campaigns, status=ACTIVE), then their creatives (query, entity=creatives with campaign_ids).
50
+ 2. Pull creative-level metrics over time (analytics, pivot=CREATIVE, entity_type=campaign with the active campaign IDs, time_granularity=DAILY, fields=impressions,clicks,costInLocalCurrency,dateRange,pivotValues).
51
+ 3. Flag creatives with high cumulative impressions and declining click-through trend.
52
+ 4. Group fatigued creatives by campaign and estimate wasted spend.
53
+ 5. Return prioritized creative refresh recommendations with rationale.`,
54
+ requiredTools: ['query', 'analytics']
55
+ },
56
+ campaign_performance_audit: {
57
+ name: 'campaign_performance_audit',
58
+ description: 'Audit account performance by campaign group and campaign',
59
+ arguments: [
60
+ {
61
+ name: 'account_id',
62
+ description: 'LinkedIn ad account ID (numeric, without URN prefix)',
63
+ required: true
64
+ },
65
+ {
66
+ name: 'start',
67
+ description: 'Analytics start date (YYYY-MM-DD)',
68
+ required: true
69
+ },
70
+ {
71
+ name: 'end',
72
+ description: 'Analytics end date (YYYY-MM-DD, defaults to today)',
73
+ required: false,
74
+ default: 'today'
75
+ }
76
+ ],
77
+ template: `Run a performance audit for LinkedIn ad account {{account_id}} from {{start}} to {{end}}:
78
+
79
+ 1. Pull campaign groups and campaigns with status and budget fields (query).
80
+ 2. Pull metrics pivoted by CAMPAIGN_GROUP and by CAMPAIGN (analytics, fields=impressions,clicks,costInLocalCurrency,externalWebsiteConversions,pivotValues).
81
+ 3. Compute CTR, CPC, and cost per conversion for each campaign.
82
+ 4. Rank campaigns by efficiency and flag spend concentrated in weak performers.
83
+ 5. Return a prioritized list of scale, fix, and pause recommendations.`,
84
+ requiredTools: ['query', 'analytics']
85
+ }
86
+ };
87
+
88
+ export function getPromptDefinition(name) {
89
+ return PROMPT_TEMPLATES[name] || null;
90
+ }
91
+
92
+ export function getPromptsList() {
93
+ return Object.values(PROMPT_TEMPLATES).map((prompt) => ({
94
+ name: prompt.name,
95
+ description: prompt.description,
96
+ arguments: prompt.arguments
97
+ }));
98
+ }
99
+
100
+ export function renderPrompt(name, args = {}) {
101
+ const template = PROMPT_TEMPLATES[name];
102
+ if (!template) {
103
+ throw new Error(`Unknown prompt: ${name}. Available prompts: ${Object.keys(PROMPT_TEMPLATES).join(', ')}`);
104
+ }
105
+
106
+ const missing = template.arguments
107
+ .filter((argument) => argument.required && !args[argument.name])
108
+ .map((argument) => argument.name);
109
+
110
+ if (missing.length > 0) {
111
+ throw new Error(`Missing required arguments for prompt "${name}": ${missing.join(', ')}`);
112
+ }
113
+
114
+ let text = template.template;
115
+
116
+ for (const argument of template.arguments) {
117
+ const value = args[argument.name] ?? argument.default;
118
+ if (value !== undefined) {
119
+ text = text.replace(new RegExp(`\\{\\{${argument.name}\\}\\}`, 'g'), String(value));
120
+ }
121
+ }
122
+
123
+ return {
124
+ messages: [
125
+ {
126
+ role: 'user',
127
+ content: {
128
+ type: 'text',
129
+ text
130
+ }
131
+ }
132
+ ]
133
+ };
134
+ }
@@ -0,0 +1,63 @@
1
+ # LinkedIn Ads Analytics Field Reference
2
+
3
+ Reference for the `analytics` tool, which wraps `GET /rest/adAnalytics?q=analytics`.
4
+
5
+ ## Pivots Supported by This Server
6
+
7
+ | Pivot | Groups results by |
8
+ |-------|--------------------|
9
+ | `ACCOUNT` | Ad account |
10
+ | `CAMPAIGN_GROUP` | Campaign group |
11
+ | `CAMPAIGN` | Campaign |
12
+ | `CREATIVE` | Creative |
13
+
14
+ LinkedIn also offers demographic pivots (e.g. `MEMBER_COMPANY`, `MEMBER_JOB_TITLE`, `MEMBER_INDUSTRY`) which this server does not expose; demographic reporting has extra minimum-audience restrictions and different field availability.
15
+
16
+ ## Time Granularity
17
+
18
+ | Value | Meaning |
19
+ |-------|---------|
20
+ | `ALL` | One row per pivot value for the whole date range (default) |
21
+ | `DAILY` | One row per pivot value per day |
22
+ | `MONTHLY` | One row per pivot value per calendar month |
23
+
24
+ ## Default Fields
25
+
26
+ If you pass no `fields`, the server requests:
27
+
28
+ ```
29
+ impressions, clicks, costInLocalCurrency, externalWebsiteConversions, dateRange, pivotValues
30
+ ```
31
+
32
+ `dateRange` and `pivotValues` are dimensional fields (they tell you which slice a row describes); the rest are metrics. LinkedIn allows at most **20 metric fields per call** and returns only `impressions` and `clicks` if you omit the `fields` parameter entirely.
33
+
34
+ ## Commonly Used Metric Fields
35
+
36
+ | Field | Description |
37
+ |-------|-------------|
38
+ | `impressions` | Sponsored impressions served |
39
+ | `clicks` | Chargeable clicks |
40
+ | `costInLocalCurrency` | Spend in the account currency |
41
+ | `costInUsd` | Spend converted to USD |
42
+ | `externalWebsiteConversions` | Conversions tracked via the Insight Tag |
43
+ | `externalWebsitePostClickConversions` | Post-click conversions |
44
+ | `externalWebsitePostViewConversions` | Post-view (view-through) conversions |
45
+ | `landingPageClicks` | Clicks to the landing page |
46
+ | `likes` | Reactions on sponsored content |
47
+ | `comments` | Comments on sponsored content |
48
+ | `shares` | Shares of sponsored content |
49
+ | `follows` | Follows generated |
50
+ | `totalEngagements` | All chargeable and free engagements |
51
+ | `videoViews` | Video views (2+ continuous seconds or click) |
52
+ | `videoCompletions` | Video watched to 97-100% |
53
+ | `oneClickLeads` | Leads from Lead Gen Forms |
54
+ | `oneClickLeadFormOpens` | Lead Gen Form opens |
55
+ | `conversionValueInLocalCurrency` | Value of conversions in account currency |
56
+
57
+ Derived rates (CTR, CPC, CPM, cost per conversion) are not returned by the API; compute them from the raw fields.
58
+
59
+ ## Practical Limits
60
+
61
+ - No pagination: responses are capped at 15,000 elements. Narrow the date range, granularity, or entity list if you hit the cap.
62
+ - Long entity URN lists can push the request URL past server limits (HTTP 414); split large ID lists across multiple calls.
63
+ - Metrics are eventually consistent; very recent data (same day) may still change.
@@ -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: 'linkedinads://reference',
14
+ name: 'LinkedIn Marketing API Reference',
15
+ description: 'Core versioned REST endpoints, required headers, Rest.li 2.0 encoding, and finder syntax for LinkedIn Ads workflows',
16
+ mimeType: 'text/markdown'
17
+ },
18
+ {
19
+ uri: 'linkedinads://analytics-fields',
20
+ name: 'LinkedIn Ads Analytics Field Reference',
21
+ description: 'adAnalytics pivots, time granularities, and commonly used metric fields with defaults',
22
+ mimeType: 'text/markdown'
23
+ },
24
+ {
25
+ uri: 'linkedinads://rate-limits',
26
+ name: 'LinkedIn 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
+ 'linkedinads://reference': 'marketing-api-reference.md',
34
+ 'linkedinads://analytics-fields': 'analytics-fields-reference.md',
35
+ 'linkedinads://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,98 @@
1
+ # LinkedIn Marketing API Reference
2
+
3
+ LinkedIn Ads uses the versioned REST API at `https://api.linkedin.com/rest`. Every request needs three headers:
4
+
5
+ | Header | Value |
6
+ |--------|-------|
7
+ | `Authorization` | `Bearer <access token>` |
8
+ | `LinkedIn-Version` | `YYYYMM` (this server defaults to `202605`, override via `LINKEDIN_ADS_API_VERSION`) |
9
+ | `X-Restli-Protocol-Version` | `2.0.0` |
10
+
11
+ API versions are released monthly and supported for roughly one year. If requests start failing with version errors, set `LINKEDIN_ADS_API_VERSION` to a newer `YYYYMM` value.
12
+
13
+ ## Rest.li 2.0 Query Encoding
14
+
15
+ Structured query parameters are NOT standard URL encoding:
16
+
17
+ - List parameters: `campaigns=List(urn%3Ali%3AsponsoredCampaign%3A123,urn%3Ali%3AsponsoredCampaign%3A456)` — the `List(` wrapper, commas, and parens are literal; each item (URNs included) is percent-encoded.
18
+ - Date ranges: `dateRange=(start:(year:2026,month:6,day:1),end:(year:2026,month:6,day:30))` — parens, colons, and commas literal; no leading zeros.
19
+ - Finder search filters: `search=(status:(values:List(ACTIVE,PAUSED)))`.
20
+
21
+ Never run these through `URLSearchParams`; it would percent-encode the structural characters and break the request.
22
+
23
+ ## Core Endpoints Used by This Server
24
+
25
+ ### Ad Accounts
26
+
27
+ ```
28
+ GET /rest/adAccounts?q=search&pageSize=1000
29
+ GET /rest/adAccounts?q=search&search=(status:(values:List(ACTIVE)),type:(values:List(BUSINESS)))
30
+ ```
31
+
32
+ - Cursor pagination: pass `pageSize` (max 1000) and the `pageToken` from the previous response's `metadata.nextPageToken`.
33
+ - Account statuses: `ACTIVE`, `CANCELED`, `DRAFT`, `PENDING_DELETION`, `REMOVED`.
34
+ - Account types: `BUSINESS`, `ENTERPRISE`.
35
+
36
+ ### Campaign Groups and Campaigns
37
+
38
+ ```
39
+ GET /rest/adAccounts/{accountId}/adCampaignGroups?q=search
40
+ GET /rest/adAccounts/{accountId}/adCampaigns?q=search&search=(status:(values:List(ACTIVE)))
41
+ POST /rest/adAccounts/{accountId}/adCampaigns (create; 201 + id in x-restli-id header)
42
+ POST /rest/adAccounts/{accountId}/adCampaigns/{id} (partial update; see below)
43
+ ```
44
+
45
+ - Campaign statuses: `ACTIVE`, `PAUSED`, `ARCHIVED`, `COMPLETED`, `CANCELED`, `DRAFT`, `PENDING_DELETION`, `REMOVED`.
46
+ - Same finder/pagination pattern as adAccounts (`pageSize` max 1000).
47
+
48
+ ### Creatives
49
+
50
+ ```
51
+ GET /rest/adAccounts/{accountId}/creatives?q=criteria&intendedStatuses=List(ACTIVE)&campaigns=List(urn%3Ali%3AsponsoredCampaign%3A123)
52
+ POST /rest/adAccounts/{accountId}/creatives (create; id URN in x-restli-id header)
53
+ POST /rest/adAccounts/{accountId}/creatives/{encodedUrn} (partial update)
54
+ ```
55
+
56
+ - Creatives use the `criteria` finder with top-level List params, not a `search` expression.
57
+ - `pageSize` max is 100 for creatives.
58
+ - Creative IDs are URNs (`urn:li:sponsoredCreative:123`) and must be percent-encoded in resource paths.
59
+ - Creative status lives in `intendedStatus`; actual delivery state is reflected by `isServing` / `servingHoldReasons`.
60
+
61
+ ### Partial Updates
62
+
63
+ Updates and status changes use Rest.li partial update: POST to the entity item with header `X-RestLi-Method: PARTIAL_UPDATE` and body:
64
+
65
+ ```json
66
+ { "patch": { "$set": { "status": "PAUSED" } } }
67
+ ```
68
+
69
+ A successful partial update returns `204 No Content`.
70
+
71
+ ### Analytics
72
+
73
+ ```
74
+ GET /rest/adAnalytics?q=analytics&pivot=CAMPAIGN&dateRange=(start:(year:2026,month:6,day:1))&timeGranularity=DAILY&campaigns=List(urn%3Ali%3AsponsoredCampaign%3A123)&fields=impressions,clicks,costInLocalCurrency,externalWebsiteConversions,dateRange,pivotValues
75
+ ```
76
+
77
+ - No pagination; responses are capped at 15,000 elements.
78
+ - At most 20 metric fields per call.
79
+ - Very long URN lists can exceed URL length limits (HTTP 414); split entity lists across calls if needed.
80
+
81
+ ## Response Envelope
82
+
83
+ Finder responses return:
84
+
85
+ ```json
86
+ {
87
+ "elements": [ ... ],
88
+ "metadata": { "nextPageToken": "..." },
89
+ "paging": { ... }
90
+ }
91
+ ```
92
+
93
+ Error responses include `status`, `code`/`serviceErrorCode`, and `message` fields.
94
+
95
+ ## Auth
96
+
97
+ - Static token: `LINKEDIN_ADS_ACCESS_TOKEN` (60-day member tokens from the 3-legged OAuth flow).
98
+ - Programmatic refresh: POST `https://www.linkedin.com/oauth/v2/accessToken` (form-encoded) with `grant_type=refresh_token`, `refresh_token`, `client_id`, `client_secret`. Refresh tokens last one year from issuance and are not extended by refreshing.
@@ -0,0 +1,34 @@
1
+ # LinkedIn Ads Rate Limits and Quotas
2
+
3
+ LinkedIn Marketing API throttling is quota-based: each application has daily application-level and member-level (per token) limits per endpoint. Exact numbers vary by endpoint and program tier and are visible in the LinkedIn Developer Portal under your app's Analytics > Quotas view. Quotas reset daily (midnight UTC).
4
+
5
+ ## What This MCP Server Does
6
+
7
+ - Treats HTTP `429` as the throttling signal.
8
+ - Reads the `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. If the retry is throttled again, the error is surfaced to the caller.
20
+
21
+ ## Timeout Behavior
22
+
23
+ - Requests use a default timeout of `30000` ms.
24
+ - Override with `LINKEDIN_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 `fields` projections narrow on analytics calls (max 20 metrics anyway).
30
+ - Use `limit` deliberately; every extra page is another quota hit.
31
+ - Batch analytics by passing multiple entity IDs in one call instead of one call per entity — but watch URL length (HTTP 414 means split the list).
32
+ - Cache read-heavy metadata (account and campaign lists) where possible.
33
+ - Stagger high-volume jobs; daily quotas exhaust silently until requests start failing with 429s.
34
+ - If you consistently hit quota ceilings, apply for a higher access tier in the LinkedIn Developer Portal.