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 +21 -0
- package/README.md +203 -0
- package/config.example.json +19 -0
- package/dist/build-info.json +1 -0
- package/dist/errors.d.ts +17 -0
- package/dist/errors.js +63 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +386 -0
- package/dist/tools.d.ts +2 -0
- package/dist/tools.js +106 -0
- package/package.json +59 -0
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"}
|
package/dist/errors.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
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);
|
package/dist/tools.d.ts
ADDED
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
|
+
}
|