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.
- package/dist/build-info.json +1 -1
- package/dist/index.js +32 -7
- package/dist/tools.js +3 -3
- package/package.json +3 -2
package/dist/build-info.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"sha":"
|
|
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(
|
|
15
|
+
const __cliPkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
|
|
14
16
|
// Log build fingerprint at startup
|
|
15
17
|
try {
|
|
16
|
-
const
|
|
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(
|
|
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=${
|
|
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=${
|
|
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
|
-
|
|
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.
|
|
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",
|