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.
@@ -1 +1 @@
1
- {"sha":"c1e5873","builtAt":"2026-03-13T17:44:17.111Z"}
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
- console.error(`[build] SHA: ${buildInfo.sha} (${buildInfo.builtAt})`);
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 = `[STARTUP ERROR] Missing required credentials: ${creds.missing.join(", ")}. Check run-mcp.sh and Keychain entries.`;
48
- console.error(msg);
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
- console.error("[startup] Credentials validated: token env vars present");
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
- console.error("[token] Rotated refresh token persisted to Keychain");
103
+ logger.info("Rotated refresh token persisted to Keychain");
103
104
  }
104
105
  catch (err) {
105
- console.error("[token] WARNING: Failed to persist rotated refresh token to Keychain:", err);
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
- const resp = await fetch(url, {
118
- method: "GET",
119
- headers: {
120
- "Authorization": `Bearer ${token}`,
121
- "LinkedIn-Version": this.config.api.version,
122
- "X-Restli-Protocol-Version": "2.0.0",
123
- },
124
- });
125
- if (!resp.ok) {
126
- const text = await resp.text();
127
- const error = Object.assign(new Error(`LinkedIn API error: ${resp.status} ${text}`), { status: resp.status });
128
- throw classifyError(error);
129
- }
130
- return await resp.json();
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
- const resp = await fetch(fullUrl, {
136
- method: "GET",
137
- headers: {
138
- "Authorization": `Bearer ${token}`,
139
- "LinkedIn-Version": this.config.api.version,
140
- "X-Restli-Protocol-Version": "2.0.0",
141
- },
142
- });
143
- if (!resp.ok) {
144
- const text = await resp.text();
145
- const error = Object.assign(new Error(`LinkedIn API error: ${resp.status} ${text}`), { status: resp.status });
146
- throw classifyError(error);
147
- }
148
- return await resp.json();
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
- console.error("MCP LinkedIn Ads server running");
391
+ logger.info("MCP LinkedIn Ads server running");
385
392
  }
386
- main().catch(console.error);
393
+ main().catch((err) => logger.error({ err }, "Server failed to start"));
@@ -0,0 +1,3 @@
1
+ export declare const logger: import("pino").Logger<never>;
2
+ export declare function safeResponse<T>(data: T, context: string): T;
3
+ export declare function withResilience<T>(fn: () => Promise<T>, operationName: string): Promise<T>;
@@ -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
- "version": "1.0.0",
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/drak-marketing/mcp-linkedin-ads"
41
+ "url": "https://github.com/mharnett/mcp-linkedin-ads"
41
42
  },
42
43
  "bugs": {
43
- "url": "https://github.com/drak-marketing/mcp-linkedin-ads/issues"
44
+ "url": "https://github.com/mharnett/mcp-linkedin-ads/issues"
44
45
  },
45
- "homepage": "https://github.com/drak-marketing/mcp-linkedin-ads#readme",
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": {