@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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 channel47
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,177 @@
1
+ # @channel47/linkedin-ads-mcp
2
+
3
+ MCP server for LinkedIn Ads using the LinkedIn Marketing API (versioned REST, default `LinkedIn-Version: 202605`).
4
+
5
+ This server exposes four tools expected by channel47 LinkedIn workflows:
6
+
7
+ - `list_accounts`
8
+ - `query`
9
+ - `analytics`
10
+ - `mutate`
11
+
12
+ ## Installation
13
+
14
+ ### Standalone
15
+
16
+ ```bash
17
+ npx @channel47/linkedin-ads-mcp@latest
18
+ ```
19
+
20
+ ### Monorepo Development
21
+
22
+ ```bash
23
+ cd mcps
24
+ npm install
25
+ npm run test
26
+ ```
27
+
28
+ ### Claude Code
29
+
30
+ ```bash
31
+ claude mcp add linkedin-ads --env LINKEDIN_ADS_ACCESS_TOKEN=<token> -- npx @channel47/linkedin-ads-mcp@latest
32
+ ```
33
+
34
+ Or as JSON config:
35
+
36
+ ```json
37
+ {
38
+ "mcpServers": {
39
+ "linkedin-ads": {
40
+ "command": "npx",
41
+ "args": ["@channel47/linkedin-ads-mcp@latest"],
42
+ "env": {
43
+ "LINKEDIN_ADS_ACCESS_TOKEN": "<token>"
44
+ }
45
+ }
46
+ }
47
+ }
48
+ ```
49
+
50
+ ## Getting Credentials
51
+
52
+ 1. Create an app at [developer.linkedin.com](https://developer.linkedin.com/) and associate it with a LinkedIn Company Page.
53
+ 2. Request access to the **Advertising API** product (approval required).
54
+ 3. Complete the 3-legged OAuth flow with the `r_ads` scope (read) plus `rw_ads` (mutations) and `r_ads_reporting` (analytics) to obtain a member access token (valid 60 days).
55
+ 4. Either paste that token into `LINKEDIN_ADS_ACCESS_TOKEN`, or — if your app is enabled for programmatic refresh — supply client ID/secret and the refresh token (valid 1 year) and let the server refresh automatically.
56
+
57
+ ## Configuration
58
+
59
+ ### Required (one of the two auth modes)
60
+
61
+ | Variable | Description |
62
+ |----------|-------------|
63
+ | `LINKEDIN_ADS_ACCESS_TOKEN` | Static LinkedIn OAuth access token, used directly |
64
+ | `LINKEDIN_ADS_CLIENT_ID` + `LINKEDIN_ADS_CLIENT_SECRET` + `LINKEDIN_ADS_REFRESH_TOKEN` | OAuth refresh flow. When all three are set the server exchanges the refresh token at `https://www.linkedin.com/oauth/v2/accessToken`, caches the access token in memory, and refreshes ~5 minutes before expiry. Takes precedence over the static token. |
65
+
66
+ ### Optional
67
+
68
+ | Variable | Description |
69
+ |----------|-------------|
70
+ | `LINKEDIN_ADS_ACCOUNT_ID` | Default ad account ID used when `account_id` is omitted |
71
+ | `LINKEDIN_ADS_API_VERSION` | `LinkedIn-Version` header override in `YYYYMM` format (default `202605`) |
72
+ | `LINKEDIN_ADS_READ_ONLY` | Set to `true` to disable live mutations |
73
+ | `LINKEDIN_ADS_REQUEST_TIMEOUT_MS` | HTTP request timeout in milliseconds (default `30000`) |
74
+
75
+ ## Tool Reference
76
+
77
+ ### `list_accounts`
78
+
79
+ List accessible ad accounts from `GET /rest/adAccounts?q=search` with cursor (`pageSize`/`pageToken`) pagination.
80
+
81
+ **Params:**
82
+ - `status` (optional): `ACTIVE`, `CANCELED`, `DRAFT`, `PENDING_DELETION`, `REMOVED` — string or array, applied server-side via the search finder
83
+ - `type` (optional): `BUSINESS`, `ENTERPRISE`
84
+ - `limit` (optional, default 1000)
85
+
86
+ Returns id, name, status, currency, type, test flag, organization reference, and serving statuses per account.
87
+
88
+ ### `query`
89
+
90
+ Entity reads for one ad account:
91
+
92
+ | `entity` | Endpoint | Finder |
93
+ |----------|----------|--------|
94
+ | `campaigns` | `/rest/adAccounts/{id}/adCampaigns` | `q=search` |
95
+ | `campaign_groups` | `/rest/adAccounts/{id}/adCampaignGroups` | `q=search` |
96
+ | `creatives` | `/rest/adAccounts/{id}/creatives` | `q=criteria` |
97
+
98
+ **Params:**
99
+ - `entity` (required)
100
+ - `account_id` (optional if `LINKEDIN_ADS_ACCOUNT_ID` exists; accepts `123` or `urn:li:sponsoredAccount:123`)
101
+ - `status` (optional): e.g. `ACTIVE`, `PAUSED`, `DRAFT`, `ARCHIVED` — for creatives this filters `intendedStatus`
102
+ - `campaign_ids` (creatives only): restrict creatives to these campaigns
103
+ - `limit` (default 100; creatives paginate at LinkedIn's max page size of 100, others at 1000)
104
+
105
+ ### `analytics`
106
+
107
+ Metrics from `GET /rest/adAnalytics?q=analytics`.
108
+
109
+ **Params:**
110
+ - `pivot` (required): `ACCOUNT`, `CAMPAIGN_GROUP`, `CAMPAIGN`, `CREATIVE`
111
+ - `start` (required) / `end` (optional): `YYYY-MM-DD`; encoded as the Rest.li `dateRange` expression
112
+ - `time_granularity`: `ALL` (default), `DAILY`, `MONTHLY`
113
+ - `entity_type` (default `account`) + `entity_ids`: plain IDs are converted to sponsored URNs and sent as the matching facet param (`accounts=List(...)`, `campaigns=List(...)`, ...). When omitted, the report is scoped to the account.
114
+ - `fields`: defaults to `impressions, clicks, costInLocalCurrency, externalWebsiteConversions, dateRange, pivotValues`
115
+
116
+ **Notes:**
117
+ - LinkedIn allows at most **20 metric fields** per call; the server enforces this.
118
+ - adAnalytics has **no pagination** — LinkedIn caps responses at 15,000 elements. Narrow the date range or entity list if you hit the cap.
119
+
120
+ ### `mutate`
121
+
122
+ Mutation tool with dry-run safety by default.
123
+
124
+ **Operation format:**
125
+
126
+ ```json
127
+ {
128
+ "entity": "campaign",
129
+ "action": "update",
130
+ "id": "123456789",
131
+ "params": {
132
+ "dailyBudget": { "amount": "75", "currencyCode": "USD" }
133
+ }
134
+ }
135
+ ```
136
+
137
+ **Supported entities:** `campaign`, `campaign_group`, `creative`
138
+
139
+ **Supported actions:**
140
+ - `create` — POST to the entity collection under the account. New entities default to `DRAFT` status (`intendedStatus` for creatives), LinkedIn's safe non-serving state; pass an explicit status to override. The account URN is filled in automatically; creative creates require `params.campaign`.
141
+ - `update` — Rest.li partial update: POST to the entity item with `X-RestLi-Method: PARTIAL_UPDATE` and body `{ "patch": { "$set": { ... } } }`
142
+ - `pause` / `enable` / `archive` — status shortcuts via partial update (`PAUSED` / `ACTIVE` / `ARCHIVED`; creatives use `intendedStatus`)
143
+
144
+ **Top-level params:**
145
+ - `operations` (required)
146
+ - `dry_run` (default `true`): LinkedIn has **no server-side validate-only mode**, so dry run performs local validation and returns a preview of the exact requests (method, path, headers, body) without calling the API
147
+ - `partial_failure` (default `true`)
148
+
149
+ **Safety notes:**
150
+ - `archive` is hard to reverse — prefer `pause`.
151
+ - Deletion (`PENDING_DELETION`) is deliberately not exposed.
152
+ - Creative IDs are URNs (`urn:li:sponsoredCreative:123`); plain numeric IDs are accepted and converted.
153
+
154
+ ## Behavior Notes
155
+
156
+ - Every request sends `Authorization: Bearer <token>`, `LinkedIn-Version` (default `202605`, override via `LINKEDIN_ADS_API_VERSION`), and `X-Restli-Protocol-Version: 2.0.0`.
157
+ - Query strings use Rest.li 2.0 encoding: `List(...)` params keep literal parens/commas with percent-encoded items (`campaigns=List(urn%3Ali%3AsponsoredCampaign%3A123)`), and `dateRange=(start:(year:2026,month:6,day:1),end:(...))` keeps literal structure. `URLSearchParams` is never used for these.
158
+ - API retries once on HTTP `429`, using the `Retry-After` header when available (fallback `60s`).
159
+ - Requests are aborted on timeout (default `30000ms`, configurable via `LINKEDIN_ADS_REQUEST_TIMEOUT_MS`).
160
+ - Created entity IDs are read from the `x-restli-id` response header.
161
+ - LinkedIn API versions are supported for roughly one year; bump `LINKEDIN_ADS_API_VERSION` if requests start failing with version errors.
162
+
163
+ ## Read-Only Mode
164
+
165
+ Set `LINKEDIN_ADS_READ_ONLY=true` to remove the `mutate` tool entirely. Dry runs are unaffected in normal mode; live execution is blocked even if a mutate call slips through.
166
+
167
+ ## Development Commands
168
+
169
+ ```bash
170
+ cd linkedin-ads
171
+ npm test
172
+ node server/index.js
173
+ ```
174
+
175
+ ## License
176
+
177
+ MIT
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@channel47/linkedin-ads-mcp",
3
+ "version": "0.1.0",
4
+ "description": "LinkedIn Ads MCP Server - Query and mutate LinkedIn Marketing API campaign data via versioned REST",
5
+ "main": "server/index.js",
6
+ "bin": {
7
+ "linkedin-ads-mcp": "server/index.js"
8
+ },
9
+ "type": "module",
10
+ "files": [
11
+ "server/**/*.js",
12
+ "server/**/*.md",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "scripts": {
17
+ "start": "node server/index.js",
18
+ "test": "node --test *.test.js test/*.test.js",
19
+ "prepublishOnly": "npm test"
20
+ },
21
+ "keywords": [
22
+ "mcp",
23
+ "model-context-protocol",
24
+ "linkedin-ads",
25
+ "linkedin-marketing-api",
26
+ "analytics",
27
+ "advertising",
28
+ "claude"
29
+ ],
30
+ "author": "channel47",
31
+ "license": "MIT",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/channel47/mcps.git",
35
+ "directory": "linkedin-ads"
36
+ },
37
+ "bugs": {
38
+ "url": "https://github.com/channel47/mcps/issues"
39
+ },
40
+ "homepage": "https://github.com/channel47/mcps/tree/main/linkedin-ads#readme",
41
+ "dependencies": {
42
+ "@modelcontextprotocol/sdk": "^1.29.0"
43
+ },
44
+ "engines": {
45
+ "node": ">=18.0.0"
46
+ }
47
+ }
package/server/auth.js ADDED
@@ -0,0 +1,146 @@
1
+ const TOKEN_URL = 'https://www.linkedin.com/oauth/v2/accessToken';
2
+ const REFRESH_BUFFER_MS = 5 * 60 * 1000;
3
+ const DEFAULT_TOKEN_TTL_SECONDS = 3600;
4
+
5
+ const REFRESH_FLOW_ENV_VARS = [
6
+ 'LINKEDIN_ADS_CLIENT_ID',
7
+ 'LINKEDIN_ADS_CLIENT_SECRET',
8
+ 'LINKEDIN_ADS_REFRESH_TOKEN'
9
+ ];
10
+
11
+ let cachedAccessToken = null;
12
+ let accessTokenExpiresAt = 0;
13
+ let refreshPromise = null;
14
+
15
+ /**
16
+ * Check whether the full OAuth refresh-token credential set is configured.
17
+ * @returns {boolean}
18
+ */
19
+ export function hasRefreshFlowCredentials() {
20
+ return REFRESH_FLOW_ENV_VARS.every((name) => Boolean(process.env[name]));
21
+ }
22
+
23
+ /**
24
+ * Validate environment variables for LinkedIn Ads auth. Auth is valid with
25
+ * either a static LINKEDIN_ADS_ACCESS_TOKEN or the complete refresh trio
26
+ * (LINKEDIN_ADS_CLIENT_ID + LINKEDIN_ADS_CLIENT_SECRET + LINKEDIN_ADS_REFRESH_TOKEN).
27
+ * @returns {{ valid: boolean, missing: string[] }}
28
+ */
29
+ export function validateEnvironment() {
30
+ if (process.env.LINKEDIN_ADS_ACCESS_TOKEN || hasRefreshFlowCredentials()) {
31
+ return { valid: true, missing: [] };
32
+ }
33
+
34
+ const partialRefreshConfig = REFRESH_FLOW_ENV_VARS.some((name) => Boolean(process.env[name]));
35
+ const missing = partialRefreshConfig
36
+ ? REFRESH_FLOW_ENV_VARS.filter((name) => !process.env[name])
37
+ : ['LINKEDIN_ADS_ACCESS_TOKEN'];
38
+
39
+ return {
40
+ valid: false,
41
+ missing
42
+ };
43
+ }
44
+
45
+ function isCachedTokenValid() {
46
+ return Boolean(cachedAccessToken) && Date.now() < accessTokenExpiresAt - REFRESH_BUFFER_MS;
47
+ }
48
+
49
+ async function parseTokenResponse(response) {
50
+ let payload;
51
+ try {
52
+ payload = await response.json();
53
+ } catch (error) {
54
+ throw new Error(`Failed parsing LinkedIn OAuth token response: ${error.message}`);
55
+ }
56
+
57
+ if (!response.ok) {
58
+ const detail = payload?.error_description || payload?.error || 'unknown_error';
59
+ throw new Error(`LinkedIn OAuth token refresh failed (${response.status}): ${detail}`);
60
+ }
61
+
62
+ if (!payload?.access_token) {
63
+ throw new Error('LinkedIn OAuth token response missing access_token');
64
+ }
65
+
66
+ return payload;
67
+ }
68
+
69
+ async function performAccessTokenRefresh() {
70
+ const body = new URLSearchParams({
71
+ grant_type: 'refresh_token',
72
+ refresh_token: process.env.LINKEDIN_ADS_REFRESH_TOKEN,
73
+ client_id: process.env.LINKEDIN_ADS_CLIENT_ID,
74
+ client_secret: process.env.LINKEDIN_ADS_CLIENT_SECRET
75
+ });
76
+
77
+ const response = await fetch(TOKEN_URL, {
78
+ method: 'POST',
79
+ headers: {
80
+ 'Content-Type': 'application/x-www-form-urlencoded'
81
+ },
82
+ body
83
+ });
84
+
85
+ const payload = await parseTokenResponse(response);
86
+ const expiresInMs = Number(payload.expires_in ?? DEFAULT_TOKEN_TTL_SECONDS) * 1000;
87
+
88
+ cachedAccessToken = payload.access_token;
89
+ accessTokenExpiresAt = Date.now() + expiresInMs;
90
+
91
+ return cachedAccessToken;
92
+ }
93
+
94
+ /**
95
+ * Force a token refresh via the LinkedIn OAuth refresh_token grant.
96
+ * Concurrent callers share a single in-flight refresh.
97
+ * @returns {Promise<string>}
98
+ */
99
+ export async function refreshAccessToken() {
100
+ if (!refreshPromise) {
101
+ refreshPromise = performAccessTokenRefresh().finally(() => {
102
+ refreshPromise = null;
103
+ });
104
+ }
105
+
106
+ return refreshPromise;
107
+ }
108
+
109
+ /**
110
+ * Return a valid LinkedIn access token. Prefers the OAuth refresh flow when
111
+ * the full credential trio is configured (tokens are cached in memory and
112
+ * refreshed ~5 minutes before expiry); otherwise the static
113
+ * LINKEDIN_ADS_ACCESS_TOKEN is used directly.
114
+ * @returns {Promise<string>}
115
+ */
116
+ export async function getAccessToken() {
117
+ if (hasRefreshFlowCredentials()) {
118
+ if (isCachedTokenValid()) {
119
+ return cachedAccessToken;
120
+ }
121
+
122
+ return refreshAccessToken();
123
+ }
124
+
125
+ if (cachedAccessToken) {
126
+ return cachedAccessToken;
127
+ }
128
+
129
+ const { valid, missing } = validateEnvironment();
130
+ if (!valid) {
131
+ throw new Error(`Missing required environment variables: ${missing.join(', ')}`);
132
+ }
133
+
134
+ cachedAccessToken = String(process.env.LINKEDIN_ADS_ACCESS_TOKEN);
135
+ accessTokenExpiresAt = Number.MAX_SAFE_INTEGER;
136
+ return cachedAccessToken;
137
+ }
138
+
139
+ /**
140
+ * Clear the in-memory token cache (used by tests and local dev flows).
141
+ */
142
+ export function clearAuthCache() {
143
+ cachedAccessToken = null;
144
+ accessTokenExpiresAt = 0;
145
+ refreshPromise = null;
146
+ }
package/server/http.js ADDED
@@ -0,0 +1,216 @@
1
+ import { getAccessToken } from './auth.js';
2
+ import { invalidParamsError } from './utils/errors.js';
3
+ import { buildQueryString } from './utils/restli.js';
4
+
5
+ /**
6
+ * Base URL for all LinkedIn Marketing API (versioned REST) requests.
7
+ */
8
+ export const LINKEDIN_BASE_URL = 'https://api.linkedin.com/rest';
9
+ /**
10
+ * Default LinkedIn-Version header (YYYYMM). Override with LINKEDIN_ADS_API_VERSION.
11
+ */
12
+ export const DEFAULT_API_VERSION = '202605';
13
+ /**
14
+ * Rest.li protocol version required on every request.
15
+ */
16
+ export const RESTLI_PROTOCOL_VERSION = '2.0.0';
17
+
18
+ const DEFAULT_TIMEOUT_MS = 30_000;
19
+ const DEFAULT_RATE_LIMIT_SLEEP_MS = 60_000;
20
+
21
+ function sleep(ms) {
22
+ return new Promise((resolve) => {
23
+ setTimeout(resolve, ms);
24
+ });
25
+ }
26
+
27
+ /**
28
+ * Resolve the LinkedIn-Version header value (YYYYMM). Invalid overrides fall
29
+ * back to the default version.
30
+ * @returns {string}
31
+ */
32
+ export function getApiVersion() {
33
+ const configured = String(process.env.LINKEDIN_ADS_API_VERSION || '').trim();
34
+ return /^\d{6}$/.test(configured) ? configured : DEFAULT_API_VERSION;
35
+ }
36
+
37
+ function normalizePath(path) {
38
+ if (!path) {
39
+ throw new Error('API path is required');
40
+ }
41
+
42
+ return path.startsWith('/') ? path : `/${path}`;
43
+ }
44
+
45
+ function buildUrl(path, params = {}) {
46
+ const queryString = buildQueryString(params);
47
+ const base = `${LINKEDIN_BASE_URL}${normalizePath(path)}`;
48
+ return queryString ? `${base}?${queryString}` : base;
49
+ }
50
+
51
+ async function parseResponse(response) {
52
+ if (response.status === 204) {
53
+ return {};
54
+ }
55
+
56
+ const contentType = response.headers.get('content-type') || '';
57
+
58
+ if (contentType.includes('application/json')) {
59
+ return response.json();
60
+ }
61
+
62
+ const text = await response.text();
63
+ if (!text) {
64
+ return {};
65
+ }
66
+
67
+ try {
68
+ return JSON.parse(text);
69
+ } catch {
70
+ return text;
71
+ }
72
+ }
73
+
74
+ function extractErrorMessage(payload) {
75
+ if (payload?.message) {
76
+ return payload.message;
77
+ }
78
+
79
+ if (payload?.error_description) {
80
+ return payload.error_description;
81
+ }
82
+
83
+ if (typeof payload === 'string' && payload) {
84
+ return payload;
85
+ }
86
+
87
+ return 'Unknown LinkedIn Ads API error';
88
+ }
89
+
90
+ function resolveTimeoutMs(timeoutMs) {
91
+ const candidate = timeoutMs ?? process.env.LINKEDIN_ADS_REQUEST_TIMEOUT_MS ?? DEFAULT_TIMEOUT_MS;
92
+ const parsed = Number(candidate);
93
+ if (!Number.isFinite(parsed) || parsed <= 0) {
94
+ return DEFAULT_TIMEOUT_MS;
95
+ }
96
+ return Math.floor(parsed);
97
+ }
98
+
99
+ function getRateLimitSleepMs(response) {
100
+ const retryAfter = response?.headers?.get('retry-after');
101
+ if (!retryAfter) {
102
+ return DEFAULT_RATE_LIMIT_SLEEP_MS;
103
+ }
104
+
105
+ const seconds = Number(retryAfter);
106
+ if (Number.isFinite(seconds) && seconds > 0) {
107
+ return Math.floor(seconds * 1000);
108
+ }
109
+
110
+ const retryAtMs = Date.parse(retryAfter);
111
+ if (Number.isFinite(retryAtMs)) {
112
+ const delta = retryAtMs - Date.now();
113
+ if (delta > 0) {
114
+ return Math.floor(delta);
115
+ }
116
+ }
117
+
118
+ return DEFAULT_RATE_LIMIT_SLEEP_MS;
119
+ }
120
+
121
+ /**
122
+ * Execute an authenticated LinkedIn Marketing API request with Rest.li 2.0
123
+ * query encoding, versioned headers, timeout, and single-retry rate-limit
124
+ * handling. Created-entity IDs from the x-restli-id response header are
125
+ * surfaced on the returned payload as `restliId`.
126
+ * @param {string} path
127
+ * @param {Record<string, unknown>} [params]
128
+ * @param {{
129
+ * method?: string,
130
+ * body?: unknown,
131
+ * headers?: Record<string, string>,
132
+ * timeoutMs?: number,
133
+ * retryThrottled?: boolean,
134
+ * sleep?: (ms: number) => Promise<void>
135
+ * }} [options]
136
+ * @returns {Promise<unknown>}
137
+ */
138
+ export async function linkedinRequest(
139
+ path,
140
+ params = {},
141
+ {
142
+ method = 'GET',
143
+ body,
144
+ headers = {},
145
+ timeoutMs,
146
+ retryThrottled = true,
147
+ sleep: sleepFn = sleep
148
+ } = {}
149
+ ) {
150
+ const token = await getAccessToken();
151
+ const url = buildUrl(path, params);
152
+ const resolvedTimeoutMs = resolveTimeoutMs(timeoutMs);
153
+ const controller = new AbortController();
154
+ const timeoutId = setTimeout(() => {
155
+ controller.abort();
156
+ }, resolvedTimeoutMs);
157
+
158
+ const requestOptions = {
159
+ method,
160
+ headers: {
161
+ 'Content-Type': 'application/json',
162
+ Authorization: `Bearer ${token}`,
163
+ 'LinkedIn-Version': getApiVersion(),
164
+ 'X-Restli-Protocol-Version': RESTLI_PROTOCOL_VERSION,
165
+ ...headers
166
+ },
167
+ signal: controller.signal
168
+ };
169
+
170
+ if (body !== undefined && body !== null) {
171
+ requestOptions.body = typeof body === 'string' ? body : JSON.stringify(body);
172
+ }
173
+
174
+ let response;
175
+ try {
176
+ response = await fetch(url, requestOptions);
177
+ } catch (error) {
178
+ if (error?.name === 'AbortError') {
179
+ throw new Error(`LinkedIn Ads API request timed out after ${resolvedTimeoutMs}ms`);
180
+ }
181
+ throw error;
182
+ } finally {
183
+ clearTimeout(timeoutId);
184
+ }
185
+
186
+ const payload = await parseResponse(response);
187
+
188
+ if (response.ok) {
189
+ const restliId = response.headers.get('x-restli-id') || response.headers.get('x-linkedin-id');
190
+ if (restliId && payload && typeof payload === 'object' && !Array.isArray(payload)) {
191
+ return { ...payload, restliId };
192
+ }
193
+ return payload;
194
+ }
195
+
196
+ if (response.status === 401) {
197
+ const message = extractErrorMessage(payload);
198
+ throw invalidParamsError(`LinkedIn Ads API request failed (${response.status}): ${message}`);
199
+ }
200
+
201
+ if (response.status === 429 && retryThrottled) {
202
+ await sleepFn(getRateLimitSleepMs(response));
203
+
204
+ return linkedinRequest(path, params, {
205
+ method,
206
+ body,
207
+ headers,
208
+ timeoutMs: resolvedTimeoutMs,
209
+ retryThrottled: false,
210
+ sleep: sleepFn
211
+ });
212
+ }
213
+
214
+ const message = extractErrorMessage(payload);
215
+ throw new Error(`LinkedIn Ads API request failed (${response.status}): ${message}`);
216
+ }