mcp-linkedin-ads 1.0.0 → 1.0.2
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/dist/build-info.json +1 -1
- package/dist/index.js +49 -42
- package/dist/resilience.d.ts +3 -0
- package/dist/resilience.js +80 -0
- package/package.json +7 -4
package/dist/build-info.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"sha":"
|
|
1
|
+
{"sha":"574aa73","builtAt":"2026-03-13T18:23:01.642Z"}
|
package/dist/index.js
CHANGED
|
@@ -7,11 +7,12 @@ import { join, dirname } from "path";
|
|
|
7
7
|
import { execSync } from "child_process";
|
|
8
8
|
import { LinkedInAdsAuthError, classifyError, validateCredentials, } from "./errors.js";
|
|
9
9
|
import { tools } from "./tools.js";
|
|
10
|
+
import { withResilience, safeResponse, logger } from "./resilience.js";
|
|
10
11
|
// Log build fingerprint at startup
|
|
11
12
|
try {
|
|
12
13
|
const __buildInfoDir = dirname(new URL(import.meta.url).pathname);
|
|
13
14
|
const buildInfo = JSON.parse(readFileSync(join(__buildInfoDir, "build-info.json"), "utf-8"));
|
|
14
|
-
|
|
15
|
+
logger.info({ sha: buildInfo.sha, builtAt: buildInfo.builtAt }, "Build fingerprint");
|
|
15
16
|
}
|
|
16
17
|
catch {
|
|
17
18
|
// build-info.json not present (dev mode)
|
|
@@ -44,11 +45,11 @@ class LinkedInAdsManager {
|
|
|
44
45
|
// Validate credentials at startup — fail fast
|
|
45
46
|
const creds = validateCredentials();
|
|
46
47
|
if (!creds.valid) {
|
|
47
|
-
const msg = `
|
|
48
|
-
|
|
48
|
+
const msg = `Missing required credentials: ${creds.missing.join(", ")}. Check run-mcp.sh and Keychain entries.`;
|
|
49
|
+
logger.error({ missing: creds.missing }, msg);
|
|
49
50
|
throw new LinkedInAdsAuthError(msg);
|
|
50
51
|
}
|
|
51
|
-
|
|
52
|
+
logger.info("Credentials validated: token env vars present");
|
|
52
53
|
this.refreshToken = process.env.LINKEDIN_ADS_REFRESH_TOKEN || "";
|
|
53
54
|
if (process.env.LINKEDIN_ADS_CLIENT_ID) {
|
|
54
55
|
this.config.oauth.client_id = process.env.LINKEDIN_ADS_CLIENT_ID;
|
|
@@ -99,10 +100,10 @@ class LinkedInAdsManager {
|
|
|
99
100
|
try {
|
|
100
101
|
execSync(`security delete-generic-password -a linkedin-ads-mcp -s LINKEDIN_ADS_REFRESH_TOKEN 2>/dev/null; ` +
|
|
101
102
|
`security add-generic-password -a linkedin-ads-mcp -s LINKEDIN_ADS_REFRESH_TOKEN -w "${data.refresh_token}"`);
|
|
102
|
-
|
|
103
|
+
logger.info("Rotated refresh token persisted to Keychain");
|
|
103
104
|
}
|
|
104
105
|
catch (err) {
|
|
105
|
-
|
|
106
|
+
logger.warn({ err }, "Failed to persist rotated refresh token to Keychain");
|
|
106
107
|
}
|
|
107
108
|
}
|
|
108
109
|
return this.accessToken;
|
|
@@ -114,38 +115,44 @@ class LinkedInAdsManager {
|
|
|
114
115
|
const qs = new URLSearchParams(params).toString();
|
|
115
116
|
url += (url.includes("?") ? "&" : "?") + qs;
|
|
116
117
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
118
|
+
return withResilience(async () => {
|
|
119
|
+
const resp = await fetch(url, {
|
|
120
|
+
method: "GET",
|
|
121
|
+
headers: {
|
|
122
|
+
"Authorization": `Bearer ${token}`,
|
|
123
|
+
"LinkedIn-Version": this.config.api.version,
|
|
124
|
+
"X-Restli-Protocol-Version": "2.0.0",
|
|
125
|
+
},
|
|
126
|
+
signal: AbortSignal.timeout(30_000),
|
|
127
|
+
});
|
|
128
|
+
if (!resp.ok) {
|
|
129
|
+
const text = await resp.text();
|
|
130
|
+
const error = Object.assign(new Error(`LinkedIn API error: ${resp.status} ${text}`), { status: resp.status });
|
|
131
|
+
throw classifyError(error);
|
|
132
|
+
}
|
|
133
|
+
return await resp.json();
|
|
134
|
+
}, `apiGet:${path}`);
|
|
131
135
|
}
|
|
132
136
|
// Raw GET with pre-built URL (for complex Rest.li query params)
|
|
133
137
|
async apiGetRaw(fullUrl) {
|
|
134
138
|
const token = await this.getAccessToken();
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
139
|
+
return withResilience(async () => {
|
|
140
|
+
const resp = await fetch(fullUrl, {
|
|
141
|
+
method: "GET",
|
|
142
|
+
headers: {
|
|
143
|
+
"Authorization": `Bearer ${token}`,
|
|
144
|
+
"LinkedIn-Version": this.config.api.version,
|
|
145
|
+
"X-Restli-Protocol-Version": "2.0.0",
|
|
146
|
+
},
|
|
147
|
+
signal: AbortSignal.timeout(30_000),
|
|
148
|
+
});
|
|
149
|
+
if (!resp.ok) {
|
|
150
|
+
const text = await resp.text();
|
|
151
|
+
const error = Object.assign(new Error(`LinkedIn API error: ${resp.status} ${text}`), { status: resp.status });
|
|
152
|
+
throw classifyError(error);
|
|
153
|
+
}
|
|
154
|
+
return await resp.json();
|
|
155
|
+
}, `apiGetRaw:${fullUrl.split("?")[0]}`);
|
|
149
156
|
}
|
|
150
157
|
// ============================================
|
|
151
158
|
// ACCOUNT MANAGEMENT
|
|
@@ -295,14 +302,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
295
302
|
case "linkedin_ads_list_accounts": {
|
|
296
303
|
const result = await adsManager.listAccounts();
|
|
297
304
|
return {
|
|
298
|
-
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
305
|
+
content: [{ type: "text", text: JSON.stringify(safeResponse(result, "list_accounts"), null, 2) }],
|
|
299
306
|
};
|
|
300
307
|
}
|
|
301
308
|
case "linkedin_ads_list_campaign_groups": {
|
|
302
309
|
const accountId = resolveAccountId(args?.account_id);
|
|
303
310
|
const result = await adsManager.listCampaignGroups(accountId);
|
|
304
311
|
return {
|
|
305
|
-
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
312
|
+
content: [{ type: "text", text: JSON.stringify(safeResponse(result, "list_campaign_groups"), null, 2) }],
|
|
306
313
|
};
|
|
307
314
|
}
|
|
308
315
|
case "linkedin_ads_list_campaigns": {
|
|
@@ -312,7 +319,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
312
319
|
campaignGroupId: args?.campaign_group_id,
|
|
313
320
|
});
|
|
314
321
|
return {
|
|
315
|
-
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
322
|
+
content: [{ type: "text", text: JSON.stringify(safeResponse(result, "list_campaigns"), null, 2) }],
|
|
316
323
|
};
|
|
317
324
|
}
|
|
318
325
|
case "linkedin_ads_campaign_performance": {
|
|
@@ -323,7 +330,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
323
330
|
timeGranularity: args?.time_granularity,
|
|
324
331
|
});
|
|
325
332
|
return {
|
|
326
|
-
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
333
|
+
content: [{ type: "text", text: JSON.stringify(safeResponse(result, "campaign_performance"), null, 2) }],
|
|
327
334
|
};
|
|
328
335
|
}
|
|
329
336
|
case "linkedin_ads_account_performance": {
|
|
@@ -334,7 +341,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
334
341
|
timeGranularity: args?.time_granularity,
|
|
335
342
|
});
|
|
336
343
|
return {
|
|
337
|
-
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
344
|
+
content: [{ type: "text", text: JSON.stringify(safeResponse(result, "account_performance"), null, 2) }],
|
|
338
345
|
};
|
|
339
346
|
}
|
|
340
347
|
case "linkedin_ads_analytics": {
|
|
@@ -350,7 +357,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
350
357
|
campaignGroupIds: args?.campaign_group_ids,
|
|
351
358
|
});
|
|
352
359
|
return {
|
|
353
|
-
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
360
|
+
content: [{ type: "text", text: JSON.stringify(safeResponse(result, "analytics"), null, 2) }],
|
|
354
361
|
};
|
|
355
362
|
}
|
|
356
363
|
default:
|
|
@@ -381,6 +388,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
381
388
|
async function main() {
|
|
382
389
|
const transport = new StdioServerTransport();
|
|
383
390
|
await server.connect(transport);
|
|
384
|
-
|
|
391
|
+
logger.info("MCP LinkedIn Ads server running");
|
|
385
392
|
}
|
|
386
|
-
main().catch(
|
|
393
|
+
main().catch((err) => logger.error({ err }, "Server failed to start"));
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { retry, circuitBreaker, wrap, handleAll, timeout, TimeoutStrategy, ExponentialBackoff, ConsecutiveBreaker, } from "cockatiel";
|
|
2
|
+
import pino from "pino";
|
|
3
|
+
// ============================================
|
|
4
|
+
// LOGGER
|
|
5
|
+
// ============================================
|
|
6
|
+
export const logger = pino({
|
|
7
|
+
level: process.env.LOG_LEVEL || "info",
|
|
8
|
+
...(process.env.NODE_ENV !== "test" && {
|
|
9
|
+
transport: {
|
|
10
|
+
target: "pino-pretty",
|
|
11
|
+
options: {
|
|
12
|
+
colorize: true,
|
|
13
|
+
singleLine: true,
|
|
14
|
+
translateTime: "SYS:standard",
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
}),
|
|
18
|
+
});
|
|
19
|
+
// ============================================
|
|
20
|
+
// SAFE RESPONSE (Response Size Limiting)
|
|
21
|
+
// ============================================
|
|
22
|
+
const MAX_RESPONSE_SIZE = 200_000; // 200KB
|
|
23
|
+
export function safeResponse(data, context) {
|
|
24
|
+
const jsonStr = JSON.stringify(data);
|
|
25
|
+
const sizeBytes = Buffer.byteLength(jsonStr, "utf-8");
|
|
26
|
+
if (sizeBytes > MAX_RESPONSE_SIZE) {
|
|
27
|
+
logger.warn({ sizeBytes, maxSize: MAX_RESPONSE_SIZE, context }, `Response exceeds size limit, truncating`);
|
|
28
|
+
// If it's an array, truncate
|
|
29
|
+
if (Array.isArray(data)) {
|
|
30
|
+
const truncated = data.slice(0, Math.max(1, Math.floor(data.length * 0.5)));
|
|
31
|
+
return truncated;
|
|
32
|
+
}
|
|
33
|
+
// If it's an object with items/results, truncate those
|
|
34
|
+
if (typeof data === "object" && data !== null) {
|
|
35
|
+
const obj = data;
|
|
36
|
+
for (const key of ["items", "results", "data", "rows", "elements"]) {
|
|
37
|
+
if (Array.isArray(obj[key])) {
|
|
38
|
+
obj[key] = obj[key].slice(0, Math.max(1, Math.floor(obj[key].length * 0.5)));
|
|
39
|
+
return obj;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return data;
|
|
45
|
+
}
|
|
46
|
+
// ============================================
|
|
47
|
+
// RETRY + CIRCUIT BREAKER + TIMEOUT
|
|
48
|
+
// ============================================
|
|
49
|
+
const backoff = new ExponentialBackoff({
|
|
50
|
+
initialDelay: 100,
|
|
51
|
+
maxDelay: 5_000,
|
|
52
|
+
});
|
|
53
|
+
// Individual policies
|
|
54
|
+
const retryPolicy = retry(handleAll, {
|
|
55
|
+
maxAttempts: 3,
|
|
56
|
+
backoff,
|
|
57
|
+
});
|
|
58
|
+
const circuitBreakerPolicy = circuitBreaker(handleAll, {
|
|
59
|
+
halfOpenAfter: 60_000, // 60s to attempt recovery
|
|
60
|
+
breaker: new ConsecutiveBreaker(5), // Open after 5 consecutive failures
|
|
61
|
+
});
|
|
62
|
+
const timeoutPolicy = timeout(30_000, TimeoutStrategy.Cooperative);
|
|
63
|
+
// Combine policies: timeout -> circuit breaker -> retry
|
|
64
|
+
const policy = wrap(timeoutPolicy, circuitBreakerPolicy, retryPolicy);
|
|
65
|
+
// ============================================
|
|
66
|
+
// WRAPPED API CALL WITH LOGGING
|
|
67
|
+
// ============================================
|
|
68
|
+
export async function withResilience(fn, operationName) {
|
|
69
|
+
try {
|
|
70
|
+
logger.debug({ operation: operationName }, "Starting API call");
|
|
71
|
+
const result = await policy.execute(() => fn());
|
|
72
|
+
logger.debug({ operation: operationName }, "API call succeeded");
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
77
|
+
logger.error({ operation: operationName, error: error.message, stack: error.stack }, "API call failed after retries");
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-linkedin-ads",
|
|
3
|
-
"
|
|
3
|
+
"mcpName": "io.github.mharnett/linkedin-ads",
|
|
4
|
+
"version": "1.0.2",
|
|
4
5
|
"description": "MCP server for LinkedIn Campaign Manager API with full campaign, ad group, creative, and targeting support. Production-proven with 65+ campaigns under active management.",
|
|
5
6
|
"main": "dist/index.js",
|
|
6
7
|
"types": "dist/index.d.ts",
|
|
@@ -37,17 +38,19 @@
|
|
|
37
38
|
"license": "MIT",
|
|
38
39
|
"repository": {
|
|
39
40
|
"type": "git",
|
|
40
|
-
"url": "https://github.com/
|
|
41
|
+
"url": "https://github.com/mharnett/mcp-linkedin-ads"
|
|
41
42
|
},
|
|
42
43
|
"bugs": {
|
|
43
|
-
"url": "https://github.com/
|
|
44
|
+
"url": "https://github.com/mharnett/mcp-linkedin-ads/issues"
|
|
44
45
|
},
|
|
45
|
-
"homepage": "https://github.com/
|
|
46
|
+
"homepage": "https://github.com/mharnett/mcp-linkedin-ads#readme",
|
|
46
47
|
"engines": {
|
|
47
48
|
"node": ">=18.0.0"
|
|
48
49
|
},
|
|
49
50
|
"dependencies": {
|
|
50
51
|
"@modelcontextprotocol/sdk": "^0.5.0",
|
|
52
|
+
"cockatiel": "^3.2.1",
|
|
53
|
+
"pino": "^8.21.0",
|
|
51
54
|
"zod": "^3.22.4"
|
|
52
55
|
},
|
|
53
56
|
"devDependencies": {
|