mcp-linkedin-ads 1.1.1 → 1.1.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":"acbfeb9","builtAt":"2026-04-18T18:10:49.640Z"}
1
+ {"sha":"eb446cf","builtAt":"2026-05-08T18:24:15.904Z"}
package/dist/index.js CHANGED
@@ -4,17 +4,18 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
4
4
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
5
5
  import { readFileSync, existsSync } from "fs";
6
6
  import { join, dirname } from "path";
7
+ import { fileURLToPath } from "url";
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
9
  import { LinkedInAdsAuthError, classifyError, validateCredentials, } from "./errors.js";
8
10
  import { tools } from "./tools.js";
9
11
  import { filterTools, assertWriteAllowed, isWriteEnabled } from "./writeGate.js";
10
12
  import { withResilience, safeResponse, logger } from "./resilience.js";
11
13
  import v8 from "v8";
12
14
  // CLI package info
13
- const __cliPkg = JSON.parse(readFileSync(join(dirname(new URL(import.meta.url).pathname), "..", "package.json"), "utf-8"));
15
+ const __cliPkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
14
16
  // Log build fingerprint at startup
15
17
  try {
16
- const __buildInfoDir = dirname(new URL(import.meta.url).pathname);
17
- const buildInfo = JSON.parse(readFileSync(join(__buildInfoDir, "build-info.json"), "utf-8"));
18
+ const buildInfo = JSON.parse(readFileSync(join(__dirname, "build-info.json"), "utf-8"));
18
19
  console.error(`[build] SHA: ${buildInfo.sha} (${buildInfo.builtAt})`);
19
20
  }
20
21
  catch {
@@ -60,7 +61,7 @@ if (heapLimit < 256 * 1024 * 1024) {
60
61
  // ============================================
61
62
  const envTrimmed = (key) => (process.env[key] || "").trim().replace(/^["']|["']$/g, "");
62
63
  function loadConfig() {
63
- const configPath = join(dirname(new URL(import.meta.url).pathname), "..", "config.json");
64
+ const configPath = join(__dirname, "..", "config.json");
64
65
  if (!existsSync(configPath)) {
65
66
  throw new Error(`Config file not found at ${configPath}. Create config.json from config.example.json with your client entries, ` +
66
67
  `or set env vars LINKEDIN_ACCESS_TOKEN, LINKEDIN_ADS_REFRESH_TOKEN, linkedin-client-id, and linkedin-client-secret. ` +
@@ -76,6 +77,23 @@ function getClientFromWorkingDir(config, cwd) {
76
77
  }
77
78
  return null;
78
79
  }
80
+ // Add derived `frequency` (impressions / approximateMemberReach) to each
81
+ // analytics element when both fields are present. approximateMemberReach is
82
+ // only populated by the LinkedIn API at certain pivots (CAMPAIGN, CREATIVE,
83
+ // CAMPAIGN_GROUP); at pivot=ACCOUNT it returns 0, in which case frequency is
84
+ // omitted rather than reported as Infinity.
85
+ function enrichAnalyticsResponse(raw) {
86
+ if (!raw || !Array.isArray(raw.elements))
87
+ return raw;
88
+ for (const el of raw.elements) {
89
+ const reach = Number(el?.approximateMemberReach ?? 0);
90
+ const impr = Number(el?.impressions ?? 0);
91
+ if (reach > 0 && impr > 0) {
92
+ el.frequency = +(impr / reach).toFixed(4);
93
+ }
94
+ }
95
+ return raw;
96
+ }
79
97
  // ============================================
80
98
  // LINKEDIN MARKETING API CLIENT
81
99
  // ============================================
@@ -225,7 +243,7 @@ class LinkedInAdsManager {
225
243
  const statuses = options?.status || ["ACTIVE", "PAUSED"];
226
244
  const statusList = `List(${statuses.join(",")})`;
227
245
  let searchParams = `(status:(values:${statusList}))`;
228
- let url = `${this.config.api.base_url}/adAccounts/${accountId}/adCampaigns?q=search&search=${encodeURIComponent(searchParams)}&count=100`;
246
+ let url = `${this.config.api.base_url}/adAccounts/${accountId}/adCampaigns?q=search&search=${searchParams}&count=100`;
229
247
  if (options?.campaignGroupId) {
230
248
  url += `&search.campaignGroup.values=List(urn%3Ali%3AsponsoredCampaignGroup%3A${options.campaignGroupId})`;
231
249
  }
@@ -247,12 +265,18 @@ class LinkedInAdsManager {
247
265
  "oneClickLeads", "oneClickLeadFormOpens",
248
266
  "externalWebsiteConversions", "externalWebsitePostClickConversions",
249
267
  "totalEngagements", "videoViews", "videoCompletions",
268
+ "approximateMemberReach",
250
269
  "dateRange", "pivotValues",
251
270
  ];
271
+ // LinkedIn Restli quirk (verified against /rest/adAnalytics with
272
+ // LinkedIn-Version 202602): dateRange value must use literal
273
+ // parens/colons/commas — percent-encoding them returns 400 PARAM_INVALID.
274
+ // The URN inside accounts=List(...) MUST be percent-encoded, but the
275
+ // List() wrapper itself stays literal. fields uses literal commas.
252
276
  const accountUrn = encodeURIComponent(`urn:li:sponsoredAccount:${options.accountId}`);
253
277
  let url = `${this.config.api.base_url}/adAnalytics?q=analytics` +
254
278
  `&pivot=${options.pivot}` +
255
- `&dateRange=${encodeURIComponent(dateRange)}` +
279
+ `&dateRange=${dateRange}` +
256
280
  `&timeGranularity=${granularity}` +
257
281
  `&accounts=List(${accountUrn})` +
258
282
  `&fields=${fields.join(",")}`;
@@ -268,7 +292,8 @@ class LinkedInAdsManager {
268
292
  .join(",");
269
293
  url += `&campaignGroups=List(${urns})`;
270
294
  }
271
- return await this.apiGetRaw(url);
295
+ const raw = await this.apiGetRaw(url);
296
+ return enrichAnalyticsResponse(raw);
272
297
  }
273
298
  async getCampaignPerformance(accountId, options) {
274
299
  return await this.getAnalytics({
package/dist/tools.js CHANGED
@@ -56,7 +56,7 @@ export const tools = [
56
56
  },
57
57
  {
58
58
  name: "linkedin_ads_campaign_performance",
59
- description: "Get campaign-level performance metrics (impressions, clicks, spend, conversions, leads, engagement, video views) for a date range. This is the main reporting tool for weekly slides.",
59
+ description: "Get campaign-level performance metrics (impressions, clicks, spend, conversions, leads, engagement, video views, reach, frequency) for a date range. Reach = approximateMemberReach (unique members exposed); frequency = impressions / reach (computed server-side). This is the main reporting tool for weekly slides.",
60
60
  inputSchema: {
61
61
  additionalProperties: false,
62
62
  type: "object",
@@ -71,7 +71,7 @@ export const tools = [
71
71
  },
72
72
  {
73
73
  name: "linkedin_ads_account_performance",
74
- description: "Get account-level aggregate performance metrics for a date range. Good for high-level summaries.",
74
+ description: "Get account-level aggregate performance metrics for a date range. Good for high-level summaries. Note: reach (approximateMemberReach) and the derived frequency field are not populated at pivot=ACCOUNT; query at CAMPAIGN/CAMPAIGN_GROUP via linkedin_ads_campaign_performance or linkedin_ads_analytics if you need reach.",
75
75
  inputSchema: {
76
76
  additionalProperties: false,
77
77
  type: "object",
@@ -102,7 +102,7 @@ export const tools = [
102
102
  fields: {
103
103
  type: "array",
104
104
  items: { type: "string" },
105
- description: "Metrics to return. Default: impressions, clicks, costInLocalCurrency, landingPageClicks, oneClickLeads, externalWebsiteConversions, totalEngagements, videoViews, dateRange, pivotValues",
105
+ description: "Metrics to return. Default: impressions, clicks, costInLocalCurrency, landingPageClicks, oneClickLeads, oneClickLeadFormOpens, externalWebsiteConversions, externalWebsitePostClickConversions, totalEngagements, videoViews, videoCompletions, approximateMemberReach, dateRange, pivotValues. The server adds a derived `frequency` field (impressions / approximateMemberReach) when both are non-zero. Reach is only populated at pivots CAMPAIGN, CREATIVE, and CAMPAIGN_GROUP — it returns 0 at pivot=ACCOUNT.",
106
106
  },
107
107
  campaign_ids: { type: "array", items: { type: "string" }, description: "Filter by numeric string campaign IDs" },
108
108
  campaign_group_ids: { type: "array", items: { type: "string" }, description: "Filter by campaign group IDs" },
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mcp-linkedin-ads",
3
3
  "mcpName": "io.github.mharnett/linkedin-ads",
4
- "version": "1.1.1",
4
+ "version": "1.1.2",
5
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.",
6
6
  "main": "dist/index.js",
7
7
  "bin": {
@@ -25,7 +25,8 @@
25
25
  "build": "tsc && node -e \"fs=require('fs');cp=require('child_process');sha=cp.execSync('git rev-parse --short HEAD 2>/dev/null||echo unknown').toString().trim();fs.writeFileSync('dist/build-info.json',JSON.stringify({sha,builtAt:new Date().toISOString()}))\"",
26
26
  "start": "node dist/index.js",
27
27
  "dev": "tsx src/index.ts",
28
- "test": "vitest run"
28
+ "test": "vitest run",
29
+ "smoke": "scripts/healthcheck.sh"
29
30
  },
30
31
  "keywords": [
31
32
  "mcp",