mcp-linkedin-ads 1.0.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 drak-marketing
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,203 @@
1
+ # LinkedIn Ads MCP Server
2
+
3
+ Production-grade MCP server for LinkedIn Campaign Manager API. Enables Claude to manage LinkedIn ad accounts, campaigns, ad sets, and creatives with full read/write support.
4
+
5
+ **Features:**
6
+ - 65+ production-tested tools
7
+ - Multi-account management (multiple LinkedIn ad accounts)
8
+ - Campaign, ad group, creative, and targeting management
9
+ - Targeting: demographics, interests, job titles, locations, behaviors
10
+ - Budget & bid optimization
11
+ - Campaign cloning & templating
12
+ - Safe create/update operations (validation first)
13
+
14
+ **Stats:**
15
+ - ⭐ Production-proven: 65+ active campaigns under management
16
+ - 📊 Multi-client: Flowspace, Forcepoint, Neon One
17
+ - 🔄 CTR accuracy: Uses `landingPageClicks` (not total clicks with engagement)
18
+ - ✅ Full test coverage: 40+ contract tests
19
+
20
+ ## Installation
21
+
22
+ ```bash
23
+ npm install mcp-linkedin-ads
24
+ ```
25
+
26
+ ## Configuration
27
+
28
+ 1. **Get OAuth credentials:**
29
+ - Go to [LinkedIn Developer Portal](https://www.linkedin.com/developers/apps)
30
+ - Create a new app with "Sign In with LinkedIn" + "Marketing Developer Platform"
31
+ - Scopes: `r_ads`, `rw_ads`, `w_member_social`, `r_organization_social`, `w_organization_social`
32
+
33
+ 2. **Create `config.json`:**
34
+ ```bash
35
+ cp config.example.json config.json
36
+ ```
37
+
38
+ 3. **Fill in your credentials:**
39
+ ```json
40
+ {
41
+ "oauth": {
42
+ "client_id": "YOUR_CLIENT_ID",
43
+ "client_secret": "YOUR_CLIENT_SECRET"
44
+ },
45
+ "clients": {
46
+ "default": {
47
+ "account_id": "YOUR_AD_ACCOUNT_ID",
48
+ "name": "My Account"
49
+ }
50
+ }
51
+ }
52
+ ```
53
+
54
+ 4. **Set environment variables (recommended for production):**
55
+ ```bash
56
+ export LINKEDIN_CLIENT_ID="your_client_id"
57
+ export LINKEDIN_CLIENT_SECRET="your_client_secret"
58
+ export LINKEDIN_AD_ACCOUNT_ID="your_account_id"
59
+ ```
60
+
61
+ ## Usage
62
+
63
+ ### Start the server
64
+ ```bash
65
+ npm start
66
+ ```
67
+
68
+ ### Use with Claude Code
69
+ Add to `~/.claude.json`:
70
+ ```json
71
+ {
72
+ "mcpServers": {
73
+ "linkedin-ads": {
74
+ "type": "http",
75
+ "url": "http://localhost:3001"
76
+ }
77
+ }
78
+ }
79
+ ```
80
+
81
+ ### Example API Calls
82
+ ```typescript
83
+ // Get client context
84
+ get_ads_client_context({ working_directory: "/path/to/project" })
85
+
86
+ // List campaigns
87
+ get_campaigns({ account_id: "511664399" })
88
+
89
+ // Create campaign
90
+ create_campaign({
91
+ name: "Q2 B2B Campaign",
92
+ objective: "LEAD_GENERATION",
93
+ status: "PAUSED"
94
+ })
95
+
96
+ // Get campaign insights
97
+ get_insights({
98
+ object_id: "campaign_123",
99
+ time_range: "last_7d"
100
+ })
101
+ ```
102
+
103
+ ## Key Data Conventions
104
+
105
+ ### CTR Calculation
106
+ - Always use `landingPageClicks` (LP clicks), NOT `clicks` (total clicks)
107
+ - Total clicks include social engagement (likes, comments, shares) which inflates CTR
108
+ - **This is critical for accurate campaign analysis**
109
+
110
+ ### Campaign Status
111
+ - `DRAFT` — Not yet active
112
+ - `ACTIVE` — Actively serving
113
+ - `PAUSED` — Paused manually
114
+ - `ARCHIVED` — Historical record
115
+
116
+ ### Audience Targeting
117
+ - Flexible targeting: `flexible_spec` array (OR logic between items)
118
+ - Exclude targeting: `exclude_spec` array
119
+ - Job titles, seniority levels, functions, locations all supported
120
+
121
+ ## CLI Tools
122
+
123
+ ```bash
124
+ npm run dev # Run in dev mode (tsx)
125
+ npm run build # Compile TypeScript
126
+ npm test # Run contract tests
127
+ ```
128
+
129
+ ## Architecture
130
+
131
+ **Files:**
132
+ - `src/index.ts` — MCP server, OAuth flow, tool handlers
133
+ - `src/tools.ts` — Tool schema definitions
134
+ - `src/errors.ts` — Error handling & classification
135
+ - `config.json` — Credentials & client mapping
136
+
137
+ **Error Handling:**
138
+ - OAuth errors: Clear messages for token refresh needed
139
+ - Rate limits: Automatic retry with backoff (recommended by LinkedIn)
140
+ - Invalid campaigns: Validation before creation (save API quota)
141
+
142
+ ## Development
143
+
144
+ ### Adding a New Tool
145
+ 1. Define schema in `src/tools.ts`
146
+ 2. Add handler in `src/index.ts` tool dispatch
147
+ 3. Test with contract test in `.contract.test.ts`
148
+ 4. Document in here
149
+
150
+ ### Testing
151
+ ```bash
152
+ npm test -- --run # Single run
153
+ npm test -- --watch # Watch mode
154
+ ```
155
+
156
+ ## Troubleshooting
157
+
158
+ ### `Config file not found`
159
+ ```bash
160
+ cp config.example.json config.json
161
+ # Fill in your OAuth credentials and account IDs
162
+ ```
163
+
164
+ ### `Missing required credentials`
165
+ Check that:
166
+ - `LINKEDIN_CLIENT_ID` and `LINKEDIN_CLIENT_SECRET` are set (or in config.json)
167
+ - `config.json` exists and contains at least one client with `account_id`
168
+ - OAuth tokens are valid (they expire)
169
+
170
+ ### `Rate limit exceeded`
171
+ LinkedIn enforces strict rate limits. The server includes automatic retry with exponential backoff. If you hit limits:
172
+ - Wait before retrying
173
+ - Batch operations when possible
174
+ - Reduce query frequency
175
+
176
+ ### `CTR seems too low`
177
+ Verify you're using `landingPageClicks` (LP clicks), not `clicks` (all interactions). The latter includes social engagement and will inflate CTR incorrectly.
178
+
179
+ ## License
180
+
181
+ MIT
182
+
183
+ ## Contributing
184
+
185
+ Contributions welcome! Please:
186
+ 1. Add tests for new tools
187
+ 2. Update README with new features
188
+ 3. Follow existing code style
189
+ 4. Tag release with version
190
+
191
+ ## Support
192
+
193
+ - **Issues:** GitHub issues for bugs/feature requests
194
+ - **Docs:** See `docs/` folder for detailed API reference
195
+ - **Community:** Discussions in GitHub
196
+
197
+ ---
198
+
199
+ **Maintained by:** VS Code AI team & community contributors
200
+
201
+ **Last Updated:** 2026-03-13
202
+
203
+ **Stability:** Production-ready (65+ campaigns in active management)
@@ -0,0 +1,19 @@
1
+ {
2
+ "oauth": {
3
+ "client_id": "YOUR_LINKEDIN_CLIENT_ID",
4
+ "client_secret": "YOUR_LINKEDIN_CLIENT_SECRET",
5
+ "token_url": "https://www.linkedin.com/oauth/v2/accessToken",
6
+ "scope": "r_ads,rw_ads,w_member_social,r_organization_social,w_organization_social"
7
+ },
8
+ "api": {
9
+ "base_url": "https://api.linkedin.com/rest",
10
+ "version": "202504"
11
+ },
12
+ "clients": {
13
+ "default": {
14
+ "account_id": "YOUR_LINKEDIN_AD_ACCOUNT_ID",
15
+ "name": "Primary Account",
16
+ "folder": "/path/to/working/directory"
17
+ }
18
+ }
19
+ }
@@ -0,0 +1 @@
1
+ {"sha":"c1e5873","builtAt":"2026-03-13T17:44:17.111Z"}
@@ -0,0 +1,17 @@
1
+ export declare class LinkedInAdsAuthError extends Error {
2
+ readonly cause?: unknown | undefined;
3
+ constructor(message: string, cause?: unknown | undefined);
4
+ }
5
+ export declare class LinkedInAdsRateLimitError extends Error {
6
+ readonly retryAfterMs: number;
7
+ constructor(retryAfterMs: number, cause?: unknown);
8
+ }
9
+ export declare class LinkedInAdsServiceError extends Error {
10
+ readonly cause?: unknown | undefined;
11
+ constructor(message: string, cause?: unknown | undefined);
12
+ }
13
+ export declare function validateCredentials(): {
14
+ valid: boolean;
15
+ missing: string[];
16
+ };
17
+ export declare function classifyError(error: any): Error;
package/dist/errors.js ADDED
@@ -0,0 +1,63 @@
1
+ // ============================================
2
+ // TYPED ERRORS (mirrors motion-mcp / bing-ads pattern)
3
+ // ============================================
4
+ export class LinkedInAdsAuthError extends Error {
5
+ cause;
6
+ constructor(message, cause) {
7
+ super(message);
8
+ this.cause = cause;
9
+ this.name = "LinkedInAdsAuthError";
10
+ }
11
+ }
12
+ export class LinkedInAdsRateLimitError extends Error {
13
+ retryAfterMs;
14
+ constructor(retryAfterMs, cause) {
15
+ super(`Rate limited, retry after ${retryAfterMs}ms`);
16
+ this.retryAfterMs = retryAfterMs;
17
+ this.name = "LinkedInAdsRateLimitError";
18
+ this.cause = cause;
19
+ }
20
+ }
21
+ export class LinkedInAdsServiceError extends Error {
22
+ cause;
23
+ constructor(message, cause) {
24
+ super(message);
25
+ this.cause = cause;
26
+ this.name = "LinkedInAdsServiceError";
27
+ }
28
+ }
29
+ // ============================================
30
+ // STARTUP CREDENTIAL VALIDATION
31
+ // ============================================
32
+ export function validateCredentials() {
33
+ // Need either an access token OR (refresh token + client credentials)
34
+ const hasAccessToken = !!process.env.LINKEDIN_ADS_ACCESS_TOKEN?.trim();
35
+ const hasRefreshToken = !!process.env.LINKEDIN_ADS_REFRESH_TOKEN?.trim();
36
+ if (hasAccessToken || hasRefreshToken) {
37
+ return { valid: true, missing: [] };
38
+ }
39
+ return {
40
+ valid: false,
41
+ missing: ["LINKEDIN_ADS_ACCESS_TOKEN or LINKEDIN_ADS_REFRESH_TOKEN"],
42
+ };
43
+ }
44
+ export function classifyError(error) {
45
+ const message = error?.message || String(error);
46
+ const status = error?.status;
47
+ if (status === 401 ||
48
+ status === 403 ||
49
+ message.includes("invalid_grant") ||
50
+ message.includes("OAuth token refresh failed") ||
51
+ message.includes("expired") ||
52
+ message.includes("InvalidAccessToken")) {
53
+ return new LinkedInAdsAuthError(`Auth failed: ${message}. Token may be expired. Re-authenticate and update Keychain.`, error);
54
+ }
55
+ if (status === 429 || message.includes("throttle") || message.includes("rate")) {
56
+ const retryMs = 60_000;
57
+ return new LinkedInAdsRateLimitError(retryMs, error);
58
+ }
59
+ if (status >= 500 || message.includes("ServiceUnavailable")) {
60
+ return new LinkedInAdsServiceError(`LinkedIn API server error: ${message}`, error);
61
+ }
62
+ return error;
63
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,386 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
5
+ import { readFileSync, existsSync } from "fs";
6
+ import { join, dirname } from "path";
7
+ import { execSync } from "child_process";
8
+ import { LinkedInAdsAuthError, classifyError, validateCredentials, } from "./errors.js";
9
+ import { tools } from "./tools.js";
10
+ // Log build fingerprint at startup
11
+ try {
12
+ const __buildInfoDir = dirname(new URL(import.meta.url).pathname);
13
+ const buildInfo = JSON.parse(readFileSync(join(__buildInfoDir, "build-info.json"), "utf-8"));
14
+ console.error(`[build] SHA: ${buildInfo.sha} (${buildInfo.builtAt})`);
15
+ }
16
+ catch {
17
+ // build-info.json not present (dev mode)
18
+ }
19
+ function loadConfig() {
20
+ const configPath = join(dirname(new URL(import.meta.url).pathname), "..", "config.json");
21
+ if (!existsSync(configPath)) {
22
+ throw new Error(`Config file not found at ${configPath}. Create config.json with client entries.`);
23
+ }
24
+ return JSON.parse(readFileSync(configPath, "utf-8"));
25
+ }
26
+ function getClientFromWorkingDir(config, cwd) {
27
+ for (const [key, client] of Object.entries(config.clients)) {
28
+ if (cwd.startsWith(client.folder) || cwd.toLowerCase().includes(key)) {
29
+ return client;
30
+ }
31
+ }
32
+ return null;
33
+ }
34
+ // ============================================
35
+ // LINKEDIN MARKETING API CLIENT
36
+ // ============================================
37
+ class LinkedInAdsManager {
38
+ config;
39
+ accessToken = null;
40
+ tokenExpiry = 0;
41
+ refreshToken;
42
+ constructor(config) {
43
+ this.config = config;
44
+ // Validate credentials at startup — fail fast
45
+ const creds = validateCredentials();
46
+ 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);
49
+ throw new LinkedInAdsAuthError(msg);
50
+ }
51
+ console.error("[startup] Credentials validated: token env vars present");
52
+ this.refreshToken = process.env.LINKEDIN_ADS_REFRESH_TOKEN || "";
53
+ if (process.env.LINKEDIN_ADS_CLIENT_ID) {
54
+ this.config.oauth.client_id = process.env.LINKEDIN_ADS_CLIENT_ID;
55
+ }
56
+ if (process.env.LINKEDIN_ADS_CLIENT_SECRET) {
57
+ this.config.oauth.client_secret = process.env.LINKEDIN_ADS_CLIENT_SECRET;
58
+ }
59
+ // Support direct access token (from Keychain, 60-day TTL)
60
+ if (process.env.LINKEDIN_ADS_ACCESS_TOKEN) {
61
+ this.accessToken = process.env.LINKEDIN_ADS_ACCESS_TOKEN;
62
+ this.tokenExpiry = Date.now() + 59 * 24 * 3600 * 1000; // assume ~59 days left
63
+ }
64
+ }
65
+ async getAccessToken() {
66
+ if (this.accessToken && Date.now() < this.tokenExpiry) {
67
+ return this.accessToken;
68
+ }
69
+ // If no refresh token, can't auto-renew
70
+ if (!this.refreshToken) {
71
+ if (this.accessToken) {
72
+ // Token might be expired but try it anyway
73
+ return this.accessToken;
74
+ }
75
+ throw new LinkedInAdsAuthError("No access token or refresh token available. Run oauth_flow.py to get a new token.");
76
+ }
77
+ const params = new URLSearchParams({
78
+ grant_type: "refresh_token",
79
+ client_id: this.config.oauth.client_id,
80
+ client_secret: this.config.oauth.client_secret,
81
+ refresh_token: this.refreshToken,
82
+ });
83
+ const resp = await fetch(this.config.oauth.token_url, {
84
+ method: "POST",
85
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
86
+ body: params.toString(),
87
+ });
88
+ if (!resp.ok) {
89
+ const text = await resp.text();
90
+ const error = new Error(`OAuth token refresh failed: ${resp.status} ${text}`);
91
+ throw classifyError(error);
92
+ }
93
+ const data = await resp.json();
94
+ this.accessToken = data.access_token;
95
+ this.tokenExpiry = Date.now() + (data.expires_in - 60) * 1000;
96
+ // Persist rotated refresh token to Keychain so restarts use the latest
97
+ if (data.refresh_token && data.refresh_token !== this.refreshToken) {
98
+ this.refreshToken = data.refresh_token;
99
+ try {
100
+ execSync(`security delete-generic-password -a linkedin-ads-mcp -s LINKEDIN_ADS_REFRESH_TOKEN 2>/dev/null; ` +
101
+ `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
+ }
104
+ catch (err) {
105
+ console.error("[token] WARNING: Failed to persist rotated refresh token to Keychain:", err);
106
+ }
107
+ }
108
+ return this.accessToken;
109
+ }
110
+ async apiGet(path, params) {
111
+ const token = await this.getAccessToken();
112
+ let url = `${this.config.api.base_url}${path}`;
113
+ if (params) {
114
+ const qs = new URLSearchParams(params).toString();
115
+ url += (url.includes("?") ? "&" : "?") + qs;
116
+ }
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();
131
+ }
132
+ // Raw GET with pre-built URL (for complex Rest.li query params)
133
+ async apiGetRaw(fullUrl) {
134
+ 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();
149
+ }
150
+ // ============================================
151
+ // ACCOUNT MANAGEMENT
152
+ // ============================================
153
+ async listAccounts() {
154
+ const url = `${this.config.api.base_url}/adAccounts?q=search&search=(status:(values:List(ACTIVE)))&count=100`;
155
+ return await this.apiGetRaw(url);
156
+ }
157
+ // ============================================
158
+ // CAMPAIGN MANAGEMENT
159
+ // ============================================
160
+ async listCampaignGroups(accountId) {
161
+ const url = `${this.config.api.base_url}/adAccounts/${accountId}/adCampaignGroups?q=search&search=(status:(values:List(ACTIVE,PAUSED)))&count=100`;
162
+ return await this.apiGetRaw(url);
163
+ }
164
+ async listCampaigns(accountId, options) {
165
+ const statuses = options?.status || ["ACTIVE", "PAUSED"];
166
+ const statusList = `List(${statuses.join(",")})`;
167
+ let searchParams = `(status:(values:${statusList}))`;
168
+ let url = `${this.config.api.base_url}/adAccounts/${accountId}/adCampaigns?q=search&search=${encodeURIComponent(searchParams)}&count=100`;
169
+ if (options?.campaignGroupId) {
170
+ url += `&search.campaignGroup.values=List(urn%3Ali%3AsponsoredCampaignGroup%3A${options.campaignGroupId})`;
171
+ }
172
+ return await this.apiGetRaw(url);
173
+ }
174
+ // ============================================
175
+ // ANALYTICS / REPORTING
176
+ // ============================================
177
+ buildDateRange(startDate, endDate) {
178
+ const [sy, sm, sd] = startDate.split("-").map(Number);
179
+ const [ey, em, ed] = endDate.split("-").map(Number);
180
+ return `(start:(year:${sy},month:${sm},day:${sd}),end:(year:${ey},month:${em},day:${ed}))`;
181
+ }
182
+ async getAnalytics(options) {
183
+ const dateRange = this.buildDateRange(options.startDate, options.endDate);
184
+ const granularity = options.timeGranularity || "ALL";
185
+ const fields = options.fields || [
186
+ "impressions", "clicks", "costInLocalCurrency", "landingPageClicks",
187
+ "oneClickLeads", "oneClickLeadFormOpens",
188
+ "externalWebsiteConversions", "externalWebsitePostClickConversions",
189
+ "totalEngagements", "videoViews", "videoCompletions",
190
+ "dateRange", "pivotValues",
191
+ ];
192
+ const accountUrn = encodeURIComponent(`urn:li:sponsoredAccount:${options.accountId}`);
193
+ let url = `${this.config.api.base_url}/adAnalytics?q=analytics` +
194
+ `&pivot=${options.pivot}` +
195
+ `&dateRange=${encodeURIComponent(dateRange)}` +
196
+ `&timeGranularity=${granularity}` +
197
+ `&accounts=List(${accountUrn})` +
198
+ `&fields=${fields.join(",")}`;
199
+ if (options.campaignIds && options.campaignIds.length > 0) {
200
+ const urns = options.campaignIds
201
+ .map(id => encodeURIComponent(`urn:li:sponsoredCampaign:${id}`))
202
+ .join(",");
203
+ url += `&campaigns=List(${urns})`;
204
+ }
205
+ if (options.campaignGroupIds && options.campaignGroupIds.length > 0) {
206
+ const urns = options.campaignGroupIds
207
+ .map(id => encodeURIComponent(`urn:li:sponsoredCampaignGroup:${id}`))
208
+ .join(",");
209
+ url += `&campaignGroups=List(${urns})`;
210
+ }
211
+ return await this.apiGetRaw(url);
212
+ }
213
+ async getCampaignPerformance(accountId, options) {
214
+ return await this.getAnalytics({
215
+ accountId,
216
+ startDate: options.startDate,
217
+ endDate: options.endDate,
218
+ pivot: "CAMPAIGN",
219
+ timeGranularity: options.timeGranularity,
220
+ });
221
+ }
222
+ async getAccountPerformance(accountId, options) {
223
+ return await this.getAnalytics({
224
+ accountId,
225
+ startDate: options.startDate,
226
+ endDate: options.endDate,
227
+ pivot: "ACCOUNT",
228
+ timeGranularity: options.timeGranularity,
229
+ });
230
+ }
231
+ getConfig() {
232
+ return this.config;
233
+ }
234
+ }
235
+ // ============================================
236
+ // MCP SERVER
237
+ // ============================================
238
+ const config = loadConfig();
239
+ const adsManager = new LinkedInAdsManager(config);
240
+ const server = new Server({
241
+ name: "mcp-linkedin-ads",
242
+ version: "1.0.0",
243
+ }, {
244
+ capabilities: {
245
+ tools: {},
246
+ },
247
+ });
248
+ // Handle list tools
249
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
250
+ return { tools };
251
+ });
252
+ // Handle tool calls
253
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
254
+ const { name, arguments: args } = request.params;
255
+ try {
256
+ const resolveAccountId = (accountId) => {
257
+ if (accountId)
258
+ return accountId;
259
+ const clients = Object.values(config.clients);
260
+ if (clients.length === 0)
261
+ throw new Error("No clients configured");
262
+ return clients[0].account_id;
263
+ };
264
+ switch (name) {
265
+ case "linkedin_ads_get_client_context": {
266
+ const cwd = args?.working_directory;
267
+ const client = getClientFromWorkingDir(config, cwd);
268
+ if (!client) {
269
+ return {
270
+ content: [{
271
+ type: "text",
272
+ text: JSON.stringify({
273
+ error: "No client found for working directory",
274
+ working_directory: cwd,
275
+ available_clients: Object.entries(config.clients).map(([k, v]) => ({
276
+ key: k,
277
+ name: v.name,
278
+ folder: v.folder,
279
+ })),
280
+ }, null, 2),
281
+ }],
282
+ };
283
+ }
284
+ return {
285
+ content: [{
286
+ type: "text",
287
+ text: JSON.stringify({
288
+ client_name: client.name,
289
+ account_id: client.account_id,
290
+ folder: client.folder,
291
+ }, null, 2),
292
+ }],
293
+ };
294
+ }
295
+ case "linkedin_ads_list_accounts": {
296
+ const result = await adsManager.listAccounts();
297
+ return {
298
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
299
+ };
300
+ }
301
+ case "linkedin_ads_list_campaign_groups": {
302
+ const accountId = resolveAccountId(args?.account_id);
303
+ const result = await adsManager.listCampaignGroups(accountId);
304
+ return {
305
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
306
+ };
307
+ }
308
+ case "linkedin_ads_list_campaigns": {
309
+ const accountId = resolveAccountId(args?.account_id);
310
+ const result = await adsManager.listCampaigns(accountId, {
311
+ status: args?.status,
312
+ campaignGroupId: args?.campaign_group_id,
313
+ });
314
+ return {
315
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
316
+ };
317
+ }
318
+ case "linkedin_ads_campaign_performance": {
319
+ const accountId = resolveAccountId(args?.account_id);
320
+ const result = await adsManager.getCampaignPerformance(accountId, {
321
+ startDate: args?.start_date,
322
+ endDate: args?.end_date,
323
+ timeGranularity: args?.time_granularity,
324
+ });
325
+ return {
326
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
327
+ };
328
+ }
329
+ case "linkedin_ads_account_performance": {
330
+ const accountId = resolveAccountId(args?.account_id);
331
+ const result = await adsManager.getAccountPerformance(accountId, {
332
+ startDate: args?.start_date,
333
+ endDate: args?.end_date,
334
+ timeGranularity: args?.time_granularity,
335
+ });
336
+ return {
337
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
338
+ };
339
+ }
340
+ case "linkedin_ads_analytics": {
341
+ const accountId = resolveAccountId(args?.account_id);
342
+ const result = await adsManager.getAnalytics({
343
+ accountId,
344
+ startDate: args?.start_date,
345
+ endDate: args?.end_date,
346
+ pivot: args?.pivot,
347
+ timeGranularity: args?.time_granularity,
348
+ fields: args?.fields,
349
+ campaignIds: args?.campaign_ids,
350
+ campaignGroupIds: args?.campaign_group_ids,
351
+ });
352
+ return {
353
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
354
+ };
355
+ }
356
+ default:
357
+ throw new Error(`Unknown tool: ${name}`);
358
+ }
359
+ }
360
+ catch (error) {
361
+ const classified = classifyError(error);
362
+ const isAuth = classified instanceof LinkedInAdsAuthError;
363
+ return {
364
+ content: [{
365
+ type: "text",
366
+ text: JSON.stringify({
367
+ error: true,
368
+ error_type: classified.name,
369
+ message: classified.message,
370
+ action_required: isAuth
371
+ ? "Re-authenticate LinkedIn Ads and update Keychain: security add-generic-password -a linkedin-ads-mcp -s LINKEDIN_ADS_REFRESH_TOKEN -w <new_token>"
372
+ : undefined,
373
+ details: error.stack,
374
+ }, null, 2),
375
+ }],
376
+ isError: true,
377
+ };
378
+ }
379
+ });
380
+ // Start server
381
+ async function main() {
382
+ const transport = new StdioServerTransport();
383
+ await server.connect(transport);
384
+ console.error("MCP LinkedIn Ads server running");
385
+ }
386
+ main().catch(console.error);
@@ -0,0 +1,2 @@
1
+ import { Tool } from "@modelcontextprotocol/sdk/types.js";
2
+ export declare const tools: Tool[];
package/dist/tools.js ADDED
@@ -0,0 +1,106 @@
1
+ export const tools = [
2
+ {
3
+ name: "linkedin_ads_get_client_context",
4
+ description: "Get the current client context based on working directory. Call this first to confirm which LinkedIn Ads account you're working with.",
5
+ inputSchema: {
6
+ type: "object",
7
+ properties: {
8
+ working_directory: {
9
+ type: "string",
10
+ description: "The current working directory",
11
+ },
12
+ },
13
+ required: ["working_directory"],
14
+ },
15
+ },
16
+ {
17
+ name: "linkedin_ads_list_accounts",
18
+ description: "List all active LinkedIn Ad Accounts the authenticated user has access to.",
19
+ inputSchema: {
20
+ type: "object",
21
+ properties: {},
22
+ },
23
+ },
24
+ {
25
+ name: "linkedin_ads_list_campaign_groups",
26
+ description: "List campaign groups for a LinkedIn Ad Account.",
27
+ inputSchema: {
28
+ type: "object",
29
+ properties: {
30
+ account_id: {
31
+ type: "string",
32
+ description: "The ad account ID (uses context if not provided)",
33
+ },
34
+ },
35
+ },
36
+ },
37
+ {
38
+ name: "linkedin_ads_list_campaigns",
39
+ description: "List campaigns for a LinkedIn Ad Account, with optional status and campaign group filters.",
40
+ inputSchema: {
41
+ type: "object",
42
+ properties: {
43
+ account_id: { type: "string", description: "The ad account ID" },
44
+ status: {
45
+ type: "array",
46
+ items: { type: "string" },
47
+ description: "Filter by status: ACTIVE, PAUSED, ARCHIVED, COMPLETED, CANCELED, DRAFT. Default: ACTIVE, PAUSED",
48
+ },
49
+ campaign_group_id: { type: "string", description: "Filter by campaign group ID" },
50
+ },
51
+ },
52
+ },
53
+ {
54
+ name: "linkedin_ads_campaign_performance",
55
+ 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.",
56
+ inputSchema: {
57
+ type: "object",
58
+ properties: {
59
+ account_id: { type: "string" },
60
+ start_date: { type: "string", description: "Start date YYYY-MM-DD" },
61
+ end_date: { type: "string", description: "End date YYYY-MM-DD" },
62
+ time_granularity: { type: "string", description: "ALL (default), DAILY, or MONTHLY" },
63
+ },
64
+ required: ["start_date", "end_date"],
65
+ },
66
+ },
67
+ {
68
+ name: "linkedin_ads_account_performance",
69
+ description: "Get account-level aggregate performance metrics for a date range. Good for high-level summaries.",
70
+ inputSchema: {
71
+ type: "object",
72
+ properties: {
73
+ account_id: { type: "string" },
74
+ start_date: { type: "string", description: "Start date YYYY-MM-DD" },
75
+ end_date: { type: "string", description: "End date YYYY-MM-DD" },
76
+ time_granularity: { type: "string", description: "ALL (default), DAILY, or MONTHLY" },
77
+ },
78
+ required: ["start_date", "end_date"],
79
+ },
80
+ },
81
+ {
82
+ name: "linkedin_ads_analytics",
83
+ description: "Flexible analytics query with custom pivot, fields, and filters. Use for demographic breakdowns, device splits, creative performance, etc.",
84
+ inputSchema: {
85
+ type: "object",
86
+ properties: {
87
+ account_id: { type: "string" },
88
+ start_date: { type: "string", description: "Start date YYYY-MM-DD" },
89
+ end_date: { type: "string", description: "End date YYYY-MM-DD" },
90
+ pivot: {
91
+ type: "string",
92
+ description: "Pivot dimension: ACCOUNT, CAMPAIGN_GROUP, CAMPAIGN, CREATIVE, MEMBER_COMPANY_SIZE, MEMBER_INDUSTRY, MEMBER_SENIORITY, MEMBER_JOB_FUNCTION, MEMBER_COUNTRY_V2, IMPRESSION_DEVICE_TYPE, etc.",
93
+ },
94
+ time_granularity: { type: "string", description: "ALL (default), DAILY, or MONTHLY" },
95
+ fields: {
96
+ type: "array",
97
+ items: { type: "string" },
98
+ description: "Metrics to return. Default: impressions, clicks, costInLocalCurrency, landingPageClicks, oneClickLeads, externalWebsiteConversions, totalEngagements, videoViews, dateRange, pivotValues",
99
+ },
100
+ campaign_ids: { type: "array", items: { type: "string" }, description: "Filter by campaign IDs" },
101
+ campaign_group_ids: { type: "array", items: { type: "string" }, description: "Filter by campaign group IDs" },
102
+ },
103
+ required: ["start_date", "end_date", "pivot"],
104
+ },
105
+ },
106
+ ];
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "mcp-linkedin-ads",
3
+ "version": "1.0.0",
4
+ "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
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "type": "module",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "README.md",
17
+ "LICENSE",
18
+ "config.example.json"
19
+ ],
20
+ "scripts": {
21
+ "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()}))\"",
22
+ "start": "node dist/index.js",
23
+ "dev": "tsx src/index.ts",
24
+ "test": "vitest run"
25
+ },
26
+ "keywords": [
27
+ "mcp",
28
+ "model-context-protocol",
29
+ "linkedin",
30
+ "ads",
31
+ "campaign-manager",
32
+ "marketing",
33
+ "automation",
34
+ "claude"
35
+ ],
36
+ "author": "drak-marketing",
37
+ "license": "MIT",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "https://github.com/drak-marketing/mcp-linkedin-ads"
41
+ },
42
+ "bugs": {
43
+ "url": "https://github.com/drak-marketing/mcp-linkedin-ads/issues"
44
+ },
45
+ "homepage": "https://github.com/drak-marketing/mcp-linkedin-ads#readme",
46
+ "engines": {
47
+ "node": ">=18.0.0"
48
+ },
49
+ "dependencies": {
50
+ "@modelcontextprotocol/sdk": "^0.5.0",
51
+ "zod": "^3.22.4"
52
+ },
53
+ "devDependencies": {
54
+ "@types/node": "^20.10.0",
55
+ "tsx": "^4.7.0",
56
+ "typescript": "^5.3.0",
57
+ "vitest": "^4.0.18"
58
+ }
59
+ }