@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 +21 -0
- package/README.md +177 -0
- package/package.json +47 -0
- package/server/auth.js +146 -0
- package/server/http.js +216 -0
- package/server/index.js +254 -0
- package/server/prompts/templates.js +134 -0
- package/server/resources/analytics-fields-reference.md +63 -0
- package/server/resources/index.js +65 -0
- package/server/resources/marketing-api-reference.md +98 -0
- package/server/resources/rate-limits-and-quotas.md +34 -0
- package/server/tools/analytics.js +145 -0
- package/server/tools/list-accounts.js +117 -0
- package/server/tools/mutate.js +117 -0
- package/server/tools/query.js +147 -0
- package/server/utils/errors.js +12 -0
- package/server/utils/mutate-operations.js +211 -0
- package/server/utils/response-format.js +63 -0
- package/server/utils/restli.js +153 -0
- package/server/utils/validation.js +104 -0
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
|
+
}
|