linkedin-ads-mcp 0.2.0__tar.gz
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.
- linkedin_ads_mcp-0.2.0/.env.example +16 -0
- linkedin_ads_mcp-0.2.0/.gitignore +9 -0
- linkedin_ads_mcp-0.2.0/CHANGELOG.md +47 -0
- linkedin_ads_mcp-0.2.0/LICENSE +21 -0
- linkedin_ads_mcp-0.2.0/PKG-INFO +205 -0
- linkedin_ads_mcp-0.2.0/README.md +178 -0
- linkedin_ads_mcp-0.2.0/linkedin_ads_mcp/__init__.py +10 -0
- linkedin_ads_mcp-0.2.0/linkedin_ads_mcp/client.py +172 -0
- linkedin_ads_mcp-0.2.0/linkedin_ads_mcp/server.py +342 -0
- linkedin_ads_mcp-0.2.0/pyproject.toml +43 -0
- linkedin_ads_mcp-0.2.0/server.json +20 -0
- linkedin_ads_mcp-0.2.0/tests/test_smoke.py +37 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# LinkedIn Marketing API credentials
|
|
2
|
+
# Create an app at https://www.linkedin.com/developers/apps
|
|
3
|
+
# Request the "Advertising API" product on the app's Products tab.
|
|
4
|
+
# OAuth scopes required: r_ads, rw_ads, r_ads_reporting
|
|
5
|
+
|
|
6
|
+
LINKEDIN_CLIENT_ID=
|
|
7
|
+
LINKEDIN_CLIENT_SECRET=
|
|
8
|
+
|
|
9
|
+
# After completing the OAuth dance, paste the tokens you received.
|
|
10
|
+
# Refresh token rotates per LinkedIn's policy — keep this updated.
|
|
11
|
+
LINKEDIN_ACCESS_TOKEN=
|
|
12
|
+
LINKEDIN_REFRESH_TOKEN=
|
|
13
|
+
|
|
14
|
+
# API version — format YYYYMM. Bump when migrating.
|
|
15
|
+
# https://learn.microsoft.com/en-us/linkedin/marketing/versioning
|
|
16
|
+
LINKEDIN_API_VERSION=202604
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Changelog — `linkedin-ads-mcp`
|
|
2
|
+
|
|
3
|
+
Auto-regenerated from `git log` by `/home/support/bin/changelog-regen`,
|
|
4
|
+
called before every push by `/home/support/bin/git-sync-all` (cron `*/15 * * * *`).
|
|
5
|
+
|
|
6
|
+
**Purpose:** traceability. If a push broke something, scan dates + short SHAs
|
|
7
|
+
here; then `git show <sha>` to see the diff, `git revert <sha>` to undo.
|
|
8
|
+
|
|
9
|
+
**Format:** UTC dates, newest first. Each entry: `time — subject (sha) — N files`.
|
|
10
|
+
Body text (if present) shown as indented sub-bullets.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## 2026-05-16
|
|
15
|
+
|
|
16
|
+
- **02:48 UTC** — Publish to MCP registry as com.glitchexecutor.grow/glitch-grow-linkedin-ad-mcp v0.1.1 (`da4dce0`) — 4 files
|
|
17
|
+
- Bump to 0.1.1 (PyPI requires version increment for new mcp-name verification)
|
|
18
|
+
- Add mcp-name HTML comment to README for PyPI ownership verification
|
|
19
|
+
- Add server.json with com.glitchexecutor.grow namespace (DNS-verified via grow.glitchexecutor.com TXT)
|
|
20
|
+
|
|
21
|
+
## 2026-04-29
|
|
22
|
+
|
|
23
|
+
- **20:53 UTC** — Clarify status: media upload pattern proven in Grow social agent, port to sponsored creatives queued (`1a71d3d`) — 1 file
|
|
24
|
+
- **20:50 UTC** — Point links to grow.glitchexecutor.com (Grow product site) (`02c5c8a`) — 1 file
|
|
25
|
+
- **20:49 UTC** — Use support@glitchexecutor.com as contact (`3cc4e31`) — 2 files
|
|
26
|
+
- **20:46 UTC** — Rebrand to Glitch Grow LinkedIn Ad MCP + document hosted-app path (`b94a4fa`) — 4 files
|
|
27
|
+
- Package name: linkedin-ads-mcp → glitch-grow-linkedin-ad-mcp
|
|
28
|
+
- Console script renamed accordingly (matches PyPI naming)
|
|
29
|
+
- README leads with two onboarding paths:
|
|
30
|
+
1. DIY — apply for your own LinkedIn Marketing API approval
|
|
31
|
+
2. Hosted — connect Glitch Grow's already-approved app to your
|
|
32
|
+
LinkedIn account, skip the application + approval wait
|
|
33
|
+
- Server identifier + module docstring + Claude Desktop config snippet
|
|
34
|
+
updated to match
|
|
35
|
+
The hosted-app path is the new wedge: anyone who hits the LinkedIn
|
|
36
|
+
Marketing API approval delay can use this MCP immediately by
|
|
37
|
+
- **20:41 UTC** — Initial release: linkedin-ads-mcp 0.1.0 (`7f488e9`) — 9 files
|
|
38
|
+
MCP server for LinkedIn Marketing API. Read + write campaigns,
|
|
39
|
+
groups, analytics. Handles restli encoding edge cases (literal
|
|
40
|
+
commas in fields=, %3A inside URN values, partial-update header)
|
|
41
|
+
that bite first-time integrators.
|
|
42
|
+
Tools:
|
|
43
|
+
- list_ad_accounts, list_account_users
|
|
44
|
+
- list_campaign_groups, list_campaigns, list_creatives
|
|
45
|
+
- get_account_analytics, get_campaign_analytics
|
|
46
|
+
- create_campaign_group, create_campaign
|
|
47
|
+
- update_campaign_status, update_campaign_group_status
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nuraveda Lab
|
|
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.
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: linkedin-ads-mcp
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: LinkedIn Ads MCP — MCP server for the LinkedIn Marketing API. Read + write campaigns, analytics, and creatives from any MCP client. Maintained by Nuraveda Lab.
|
|
5
|
+
Project-URL: homepage, https://github.com/Nuraveda-Labs/linkedin-ads-mcp
|
|
6
|
+
Project-URL: repository, https://github.com/Nuraveda-Labs/linkedin-ads-mcp.git
|
|
7
|
+
Project-URL: issues, https://github.com/Nuraveda-Labs/linkedin-ads-mcp/issues
|
|
8
|
+
Author-email: Nuraveda Lab <help.nuraveda@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: LinkedIn,LinkedIn Ads,MCP,Marketing API,Model Context Protocol
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: fastmcp>=3.2.0
|
|
21
|
+
Requires-Dist: httpx>=0.27
|
|
22
|
+
Requires-Dist: mcp[cli]>=1.2.0
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
25
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
<!-- mcp-name: io.github.nuraveda-labs/linkedin-ads-mcp -->
|
|
29
|
+
|
|
30
|
+
# LinkedIn Ads MCP
|
|
31
|
+
|
|
32
|
+
[](https://www.python.org/)
|
|
33
|
+
[](LICENSE)
|
|
34
|
+
[](https://meshpilot.app)
|
|
35
|
+
|
|
36
|
+
**Model Context Protocol (MCP) server for the LinkedIn Marketing API.**
|
|
37
|
+
Read campaigns, pull analytics, create campaign groups + campaigns, flip
|
|
38
|
+
statuses — all from any MCP client (Claude Desktop, Cursor, Continue, or
|
|
39
|
+
your own agent).
|
|
40
|
+
|
|
41
|
+
There's no official LinkedIn MCP. This fills the gap with a thin,
|
|
42
|
+
correctness-first wrapper that handles LinkedIn's quirky restli encoding
|
|
43
|
+
rules so you don't have to.
|
|
44
|
+
|
|
45
|
+
> Maintained by [Nuraveda Lab](https://nuraveda.com) as open-source tooling
|
|
46
|
+
> alongside the [Mesh Pilot](https://meshpilot.app) agent suite. MIT licensed.
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## ⚡ Skip the setup — connect LinkedIn through Mesh Pilot
|
|
51
|
+
|
|
52
|
+
LinkedIn's Marketing API gate is the real hassle: you apply for the
|
|
53
|
+
**Advertising API** product, wait for approval (days, not always granted),
|
|
54
|
+
run an OAuth dance, and manage refresh-token rotation yourself.
|
|
55
|
+
|
|
56
|
+
**Don't want any of that?** [**Mesh Pilot**](https://meshpilot.app) runs this
|
|
57
|
+
MCP for you behind an already-approved LinkedIn Marketing app. Connect your
|
|
58
|
+
LinkedIn account in one click and you're driving your ad accounts from your
|
|
59
|
+
AI client immediately — **no API application, no OAuth setup, no token
|
|
60
|
+
management.**
|
|
61
|
+
|
|
62
|
+
<p>
|
|
63
|
+
<a href="https://meshpilot.app"><img src="https://img.shields.io/badge/Connect%20LinkedIn%20via%20Mesh%20Pilot-→-7c3aed?style=for-the-badge" alt="Connect LinkedIn via Mesh Pilot"></a>
|
|
64
|
+
</p>
|
|
65
|
+
|
|
66
|
+
| | Self-host (this repo) | Mesh Pilot (hosted) |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| LinkedIn Marketing API approval | you apply + wait | **already approved** |
|
|
69
|
+
| OAuth + token rotation | you manage | **handled for you** |
|
|
70
|
+
| Setup time | hours–days | **one click** |
|
|
71
|
+
| Cost | free (MIT) | see [meshpilot.app](https://meshpilot.app) |
|
|
72
|
+
| Runs in your own infra | ✅ | hosted |
|
|
73
|
+
|
|
74
|
+
Prefer to run it yourself? Keep reading — the full self-host path is below.
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Why this exists
|
|
79
|
+
|
|
80
|
+
If you've tried calling LinkedIn's `/rest/adAnalytics` endpoint by hand
|
|
81
|
+
you've probably hit walls like:
|
|
82
|
+
|
|
83
|
+
- Commas in `fields=` get URL-encoded by default HTTP clients → `400 not present in schema`
|
|
84
|
+
- URN colons inside `accounts=List(urn:li:sponsoredAccount:NNN)` need to be `%3A` but date-tuple colons must stay literal
|
|
85
|
+
- Partial updates need `X-RestLi-Method: PARTIAL_UPDATE` or they get silently ignored
|
|
86
|
+
- `runSchedule.start` must be ≥ now-ish, `totalBudget.amount` must be ≥ $100
|
|
87
|
+
- New campaigns need `politicalIntent` (LinkedIn's EU political-ad declaration)
|
|
88
|
+
|
|
89
|
+
This server has all those rules already encoded.
|
|
90
|
+
|
|
91
|
+
## Install
|
|
92
|
+
|
|
93
|
+
**From source (works today):**
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
git clone https://github.com/Nuraveda-Labs/linkedin-ads-mcp.git
|
|
97
|
+
cd linkedin-ads-mcp
|
|
98
|
+
uv pip install -e . # or: pip install -e .
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
> A PyPI release under the name `linkedin-ads-mcp` is planned. The prior
|
|
102
|
+
> package name on PyPI is `glitch-grow-linkedin-ad-mcp` (legacy identity).
|
|
103
|
+
|
|
104
|
+
## OAuth setup (self-host path)
|
|
105
|
+
|
|
106
|
+
1. Create a LinkedIn app at <https://www.linkedin.com/developers/apps>.
|
|
107
|
+
2. On the **Products** tab, request **Advertising API** (auto-approved if
|
|
108
|
+
you have an active Campaign Manager account).
|
|
109
|
+
3. Run any OAuth flow that grants the scopes `r_ads`, `rw_ads`,
|
|
110
|
+
`r_ads_reporting` — for example:
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
https://www.linkedin.com/oauth/v2/authorization?response_type=code
|
|
114
|
+
&client_id=$YOUR_CLIENT_ID
|
|
115
|
+
&redirect_uri=$YOUR_REDIRECT_URI
|
|
116
|
+
&scope=r_ads%20rw_ads%20r_ads_reporting
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
4. Exchange the code for tokens; save the access + refresh tokens.
|
|
120
|
+
5. Copy `.env.example` to `.env` and paste them.
|
|
121
|
+
|
|
122
|
+
## Run
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
# stdio (Claude Desktop, Cursor, Continue, etc.)
|
|
126
|
+
linkedin-ads-mcp
|
|
127
|
+
|
|
128
|
+
# SSE on :8000
|
|
129
|
+
linkedin-ads-mcp --transport sse --port 8000
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### Claude Desktop config
|
|
133
|
+
|
|
134
|
+
```json
|
|
135
|
+
{
|
|
136
|
+
"mcpServers": {
|
|
137
|
+
"linkedin-ads": {
|
|
138
|
+
"command": "linkedin-ads-mcp",
|
|
139
|
+
"env": {
|
|
140
|
+
"LINKEDIN_CLIENT_ID": "...",
|
|
141
|
+
"LINKEDIN_CLIENT_SECRET": "...",
|
|
142
|
+
"LINKEDIN_REFRESH_TOKEN": "..."
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Tools
|
|
150
|
+
|
|
151
|
+
### Read
|
|
152
|
+
|
|
153
|
+
| Tool | What it does |
|
|
154
|
+
|------|--------------|
|
|
155
|
+
| `list_ad_accounts()` | Every ad account the OAuth user can access |
|
|
156
|
+
| `list_account_users(account_id)` | User → role assignments |
|
|
157
|
+
| `list_campaign_groups(account_id)` | Campaign groups + total budgets |
|
|
158
|
+
| `list_campaigns(account_id)` | All campaigns + structure (no metrics) |
|
|
159
|
+
| `list_creatives(account_id)` | Creative roster |
|
|
160
|
+
| `get_account_analytics(account_id, days=14)` | Account-level totals |
|
|
161
|
+
| `get_campaign_analytics(account_id, days=14)` | Per-campaign metrics, sorted by spend |
|
|
162
|
+
|
|
163
|
+
### Write
|
|
164
|
+
|
|
165
|
+
| Tool | What it does |
|
|
166
|
+
|------|--------------|
|
|
167
|
+
| `create_campaign_group(account_id, name, total_budget=100, days=30, status="DRAFT")` | Create a group |
|
|
168
|
+
| `create_campaign(account_id, name, campaign_group_urn, daily_budget=10, …)` | Create a campaign (defaults to safe DRAFT TEXT_AD) |
|
|
169
|
+
| `update_campaign_status(account_id, campaign_id, status)` | DRAFT / ACTIVE / PAUSED / ARCHIVED |
|
|
170
|
+
| `update_campaign_group_status(account_id, group_id, status)` | Same set + CANCELED |
|
|
171
|
+
|
|
172
|
+
All write tools default to `DRAFT` so nothing goes live by accident.
|
|
173
|
+
Promote a group → ACTIVE first, then promote campaigns → PAUSED → ACTIVE
|
|
174
|
+
in two explicit steps.
|
|
175
|
+
|
|
176
|
+
## Multi-tenant pattern
|
|
177
|
+
|
|
178
|
+
LinkedIn has no MCC, but Campaign Manager has equivalent **"Manage
|
|
179
|
+
Access"** sharing. To run this MCP across multiple advertisers:
|
|
180
|
+
|
|
181
|
+
1. Each client adds your OAuth user as `CAMPAIGN_MANAGER` on their ad
|
|
182
|
+
account (Campaign Manager → Account Settings → Manage Access).
|
|
183
|
+
2. After they accept, `list_ad_accounts()` returns their account.
|
|
184
|
+
3. Pass that `account_id` to any tool call. One OAuth dance, N advertiser
|
|
185
|
+
accounts — same model as the Google Ads MCC pattern.
|
|
186
|
+
|
|
187
|
+
## Status
|
|
188
|
+
|
|
189
|
+
Read API + write API for groups + campaigns are battle-tested in
|
|
190
|
+
production. Sponsored-creative creation (image/video upload via
|
|
191
|
+
`initializeUpload` → bind to `/rest/creatives` → attach to a campaign)
|
|
192
|
+
reuses a proven `/rest/documents` + `/rest/posts` upload pattern; porting
|
|
193
|
+
it to the sponsored-ad surface is on the roadmap. PRs welcome.
|
|
194
|
+
|
|
195
|
+
## License
|
|
196
|
+
|
|
197
|
+
MIT — see [LICENSE](LICENSE).
|
|
198
|
+
|
|
199
|
+
## About
|
|
200
|
+
|
|
201
|
+
Built and maintained by [Nuraveda Lab](https://nuraveda.com), open-sourced
|
|
202
|
+
as part of the [Mesh Pilot](https://meshpilot.app) growth-tooling suite.
|
|
203
|
+
Hardened against real LinkedIn Marketing API behavior in production. If you
|
|
204
|
+
hit a restli encoding edge case we missed, open an issue with the offending
|
|
205
|
+
URL and we'll codify the fix.
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
<!-- mcp-name: io.github.nuraveda-labs/linkedin-ads-mcp -->
|
|
2
|
+
|
|
3
|
+
# LinkedIn Ads MCP
|
|
4
|
+
|
|
5
|
+
[](https://www.python.org/)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
[](https://meshpilot.app)
|
|
8
|
+
|
|
9
|
+
**Model Context Protocol (MCP) server for the LinkedIn Marketing API.**
|
|
10
|
+
Read campaigns, pull analytics, create campaign groups + campaigns, flip
|
|
11
|
+
statuses — all from any MCP client (Claude Desktop, Cursor, Continue, or
|
|
12
|
+
your own agent).
|
|
13
|
+
|
|
14
|
+
There's no official LinkedIn MCP. This fills the gap with a thin,
|
|
15
|
+
correctness-first wrapper that handles LinkedIn's quirky restli encoding
|
|
16
|
+
rules so you don't have to.
|
|
17
|
+
|
|
18
|
+
> Maintained by [Nuraveda Lab](https://nuraveda.com) as open-source tooling
|
|
19
|
+
> alongside the [Mesh Pilot](https://meshpilot.app) agent suite. MIT licensed.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## ⚡ Skip the setup — connect LinkedIn through Mesh Pilot
|
|
24
|
+
|
|
25
|
+
LinkedIn's Marketing API gate is the real hassle: you apply for the
|
|
26
|
+
**Advertising API** product, wait for approval (days, not always granted),
|
|
27
|
+
run an OAuth dance, and manage refresh-token rotation yourself.
|
|
28
|
+
|
|
29
|
+
**Don't want any of that?** [**Mesh Pilot**](https://meshpilot.app) runs this
|
|
30
|
+
MCP for you behind an already-approved LinkedIn Marketing app. Connect your
|
|
31
|
+
LinkedIn account in one click and you're driving your ad accounts from your
|
|
32
|
+
AI client immediately — **no API application, no OAuth setup, no token
|
|
33
|
+
management.**
|
|
34
|
+
|
|
35
|
+
<p>
|
|
36
|
+
<a href="https://meshpilot.app"><img src="https://img.shields.io/badge/Connect%20LinkedIn%20via%20Mesh%20Pilot-→-7c3aed?style=for-the-badge" alt="Connect LinkedIn via Mesh Pilot"></a>
|
|
37
|
+
</p>
|
|
38
|
+
|
|
39
|
+
| | Self-host (this repo) | Mesh Pilot (hosted) |
|
|
40
|
+
|---|---|---|
|
|
41
|
+
| LinkedIn Marketing API approval | you apply + wait | **already approved** |
|
|
42
|
+
| OAuth + token rotation | you manage | **handled for you** |
|
|
43
|
+
| Setup time | hours–days | **one click** |
|
|
44
|
+
| Cost | free (MIT) | see [meshpilot.app](https://meshpilot.app) |
|
|
45
|
+
| Runs in your own infra | ✅ | hosted |
|
|
46
|
+
|
|
47
|
+
Prefer to run it yourself? Keep reading — the full self-host path is below.
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Why this exists
|
|
52
|
+
|
|
53
|
+
If you've tried calling LinkedIn's `/rest/adAnalytics` endpoint by hand
|
|
54
|
+
you've probably hit walls like:
|
|
55
|
+
|
|
56
|
+
- Commas in `fields=` get URL-encoded by default HTTP clients → `400 not present in schema`
|
|
57
|
+
- URN colons inside `accounts=List(urn:li:sponsoredAccount:NNN)` need to be `%3A` but date-tuple colons must stay literal
|
|
58
|
+
- Partial updates need `X-RestLi-Method: PARTIAL_UPDATE` or they get silently ignored
|
|
59
|
+
- `runSchedule.start` must be ≥ now-ish, `totalBudget.amount` must be ≥ $100
|
|
60
|
+
- New campaigns need `politicalIntent` (LinkedIn's EU political-ad declaration)
|
|
61
|
+
|
|
62
|
+
This server has all those rules already encoded.
|
|
63
|
+
|
|
64
|
+
## Install
|
|
65
|
+
|
|
66
|
+
**From source (works today):**
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
git clone https://github.com/Nuraveda-Labs/linkedin-ads-mcp.git
|
|
70
|
+
cd linkedin-ads-mcp
|
|
71
|
+
uv pip install -e . # or: pip install -e .
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
> A PyPI release under the name `linkedin-ads-mcp` is planned. The prior
|
|
75
|
+
> package name on PyPI is `glitch-grow-linkedin-ad-mcp` (legacy identity).
|
|
76
|
+
|
|
77
|
+
## OAuth setup (self-host path)
|
|
78
|
+
|
|
79
|
+
1. Create a LinkedIn app at <https://www.linkedin.com/developers/apps>.
|
|
80
|
+
2. On the **Products** tab, request **Advertising API** (auto-approved if
|
|
81
|
+
you have an active Campaign Manager account).
|
|
82
|
+
3. Run any OAuth flow that grants the scopes `r_ads`, `rw_ads`,
|
|
83
|
+
`r_ads_reporting` — for example:
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
https://www.linkedin.com/oauth/v2/authorization?response_type=code
|
|
87
|
+
&client_id=$YOUR_CLIENT_ID
|
|
88
|
+
&redirect_uri=$YOUR_REDIRECT_URI
|
|
89
|
+
&scope=r_ads%20rw_ads%20r_ads_reporting
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
4. Exchange the code for tokens; save the access + refresh tokens.
|
|
93
|
+
5. Copy `.env.example` to `.env` and paste them.
|
|
94
|
+
|
|
95
|
+
## Run
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
# stdio (Claude Desktop, Cursor, Continue, etc.)
|
|
99
|
+
linkedin-ads-mcp
|
|
100
|
+
|
|
101
|
+
# SSE on :8000
|
|
102
|
+
linkedin-ads-mcp --transport sse --port 8000
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Claude Desktop config
|
|
106
|
+
|
|
107
|
+
```json
|
|
108
|
+
{
|
|
109
|
+
"mcpServers": {
|
|
110
|
+
"linkedin-ads": {
|
|
111
|
+
"command": "linkedin-ads-mcp",
|
|
112
|
+
"env": {
|
|
113
|
+
"LINKEDIN_CLIENT_ID": "...",
|
|
114
|
+
"LINKEDIN_CLIENT_SECRET": "...",
|
|
115
|
+
"LINKEDIN_REFRESH_TOKEN": "..."
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Tools
|
|
123
|
+
|
|
124
|
+
### Read
|
|
125
|
+
|
|
126
|
+
| Tool | What it does |
|
|
127
|
+
|------|--------------|
|
|
128
|
+
| `list_ad_accounts()` | Every ad account the OAuth user can access |
|
|
129
|
+
| `list_account_users(account_id)` | User → role assignments |
|
|
130
|
+
| `list_campaign_groups(account_id)` | Campaign groups + total budgets |
|
|
131
|
+
| `list_campaigns(account_id)` | All campaigns + structure (no metrics) |
|
|
132
|
+
| `list_creatives(account_id)` | Creative roster |
|
|
133
|
+
| `get_account_analytics(account_id, days=14)` | Account-level totals |
|
|
134
|
+
| `get_campaign_analytics(account_id, days=14)` | Per-campaign metrics, sorted by spend |
|
|
135
|
+
|
|
136
|
+
### Write
|
|
137
|
+
|
|
138
|
+
| Tool | What it does |
|
|
139
|
+
|------|--------------|
|
|
140
|
+
| `create_campaign_group(account_id, name, total_budget=100, days=30, status="DRAFT")` | Create a group |
|
|
141
|
+
| `create_campaign(account_id, name, campaign_group_urn, daily_budget=10, …)` | Create a campaign (defaults to safe DRAFT TEXT_AD) |
|
|
142
|
+
| `update_campaign_status(account_id, campaign_id, status)` | DRAFT / ACTIVE / PAUSED / ARCHIVED |
|
|
143
|
+
| `update_campaign_group_status(account_id, group_id, status)` | Same set + CANCELED |
|
|
144
|
+
|
|
145
|
+
All write tools default to `DRAFT` so nothing goes live by accident.
|
|
146
|
+
Promote a group → ACTIVE first, then promote campaigns → PAUSED → ACTIVE
|
|
147
|
+
in two explicit steps.
|
|
148
|
+
|
|
149
|
+
## Multi-tenant pattern
|
|
150
|
+
|
|
151
|
+
LinkedIn has no MCC, but Campaign Manager has equivalent **"Manage
|
|
152
|
+
Access"** sharing. To run this MCP across multiple advertisers:
|
|
153
|
+
|
|
154
|
+
1. Each client adds your OAuth user as `CAMPAIGN_MANAGER` on their ad
|
|
155
|
+
account (Campaign Manager → Account Settings → Manage Access).
|
|
156
|
+
2. After they accept, `list_ad_accounts()` returns their account.
|
|
157
|
+
3. Pass that `account_id` to any tool call. One OAuth dance, N advertiser
|
|
158
|
+
accounts — same model as the Google Ads MCC pattern.
|
|
159
|
+
|
|
160
|
+
## Status
|
|
161
|
+
|
|
162
|
+
Read API + write API for groups + campaigns are battle-tested in
|
|
163
|
+
production. Sponsored-creative creation (image/video upload via
|
|
164
|
+
`initializeUpload` → bind to `/rest/creatives` → attach to a campaign)
|
|
165
|
+
reuses a proven `/rest/documents` + `/rest/posts` upload pattern; porting
|
|
166
|
+
it to the sponsored-ad surface is on the roadmap. PRs welcome.
|
|
167
|
+
|
|
168
|
+
## License
|
|
169
|
+
|
|
170
|
+
MIT — see [LICENSE](LICENSE).
|
|
171
|
+
|
|
172
|
+
## About
|
|
173
|
+
|
|
174
|
+
Built and maintained by [Nuraveda Lab](https://nuraveda.com), open-sourced
|
|
175
|
+
as part of the [Mesh Pilot](https://meshpilot.app) growth-tooling suite.
|
|
176
|
+
Hardened against real LinkedIn Marketing API behavior in production. If you
|
|
177
|
+
hit a restli encoding edge case we missed, open an issue with the offending
|
|
178
|
+
URL and we'll codify the fix.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""LinkedIn Ads MCP — MCP server for the LinkedIn Marketing API.
|
|
2
|
+
|
|
3
|
+
Tools exposed: list_ad_accounts, list_campaign_groups, list_campaigns,
|
|
4
|
+
list_creatives, get_account_analytics, get_campaign_analytics,
|
|
5
|
+
create_campaign_group, create_campaign, update_campaign_status.
|
|
6
|
+
|
|
7
|
+
See README.md for OAuth setup and required scopes (r_ads, rw_ads,
|
|
8
|
+
r_ads_reporting).
|
|
9
|
+
"""
|
|
10
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Minimal HTTP client for LinkedIn's `/rest/*` Marketing API.
|
|
2
|
+
|
|
3
|
+
Handles:
|
|
4
|
+
- Auto-refresh of access token via refresh_token + client_id/secret
|
|
5
|
+
- Restli URL encoding rules:
|
|
6
|
+
- literal commas in `fields=...`
|
|
7
|
+
- literal colons inside `(year:Y,month:M,day:D)` tuples
|
|
8
|
+
- %3A-encoded colons inside `List(urn:...)` URN values
|
|
9
|
+
- Restli partial-update protocol (X-RestLi-Method: PARTIAL_UPDATE)
|
|
10
|
+
- Created-entity id surfacing via x-restli-id / x-linkedin-id headers
|
|
11
|
+
|
|
12
|
+
Reads its config from environment variables:
|
|
13
|
+
LINKEDIN_CLIENT_ID
|
|
14
|
+
LINKEDIN_CLIENT_SECRET
|
|
15
|
+
LINKEDIN_ACCESS_TOKEN (optional — used until first refresh)
|
|
16
|
+
LINKEDIN_REFRESH_TOKEN
|
|
17
|
+
LINKEDIN_API_VERSION (default: 202604)
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
import threading
|
|
25
|
+
import time
|
|
26
|
+
from typing import Any
|
|
27
|
+
from urllib.parse import quote
|
|
28
|
+
|
|
29
|
+
import httpx
|
|
30
|
+
|
|
31
|
+
log = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
API_HOST = "https://api.linkedin.com"
|
|
34
|
+
OAUTH_HOST = "https://www.linkedin.com"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class LinkedInError(RuntimeError):
|
|
38
|
+
"""LinkedIn API failure (auth, quota, 4xx/5xx, JSON parse)."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _env(name: str, *, required: bool = True) -> str:
|
|
42
|
+
v = os.environ.get(name, "").strip()
|
|
43
|
+
if not v and required:
|
|
44
|
+
raise LinkedInError(f"{name} not set")
|
|
45
|
+
return v
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _api_version() -> str:
|
|
49
|
+
return os.environ.get("LINKEDIN_API_VERSION", "").strip() or "202604"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ----- token cache --------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
_LOCK = threading.Lock()
|
|
55
|
+
_TOKEN: str = ""
|
|
56
|
+
_TOKEN_EXPIRES_AT: float = 0.0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def get_token() -> str:
|
|
60
|
+
global _TOKEN, _TOKEN_EXPIRES_AT
|
|
61
|
+
with _LOCK:
|
|
62
|
+
now = time.time()
|
|
63
|
+
if _TOKEN and now < _TOKEN_EXPIRES_AT - 60:
|
|
64
|
+
return _TOKEN
|
|
65
|
+
if not _TOKEN:
|
|
66
|
+
seeded = os.environ.get("LINKEDIN_ACCESS_TOKEN", "").strip()
|
|
67
|
+
if seeded:
|
|
68
|
+
_TOKEN = seeded
|
|
69
|
+
_TOKEN_EXPIRES_AT = now + 50 * 60
|
|
70
|
+
return _TOKEN
|
|
71
|
+
_refresh_locked()
|
|
72
|
+
return _TOKEN
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _refresh_locked() -> None:
|
|
76
|
+
global _TOKEN, _TOKEN_EXPIRES_AT
|
|
77
|
+
refresh = _env("LINKEDIN_REFRESH_TOKEN")
|
|
78
|
+
cid = _env("LINKEDIN_CLIENT_ID")
|
|
79
|
+
sec = _env("LINKEDIN_CLIENT_SECRET")
|
|
80
|
+
r = httpx.post(
|
|
81
|
+
f"{OAUTH_HOST}/oauth/v2/accessToken",
|
|
82
|
+
data={
|
|
83
|
+
"grant_type": "refresh_token",
|
|
84
|
+
"refresh_token": refresh,
|
|
85
|
+
"client_id": cid,
|
|
86
|
+
"client_secret": sec,
|
|
87
|
+
},
|
|
88
|
+
timeout=15,
|
|
89
|
+
)
|
|
90
|
+
if r.status_code >= 400:
|
|
91
|
+
raise LinkedInError(f"refresh failed [{r.status_code}]: {r.text[:200]}")
|
|
92
|
+
data = r.json()
|
|
93
|
+
_TOKEN = data["access_token"]
|
|
94
|
+
_TOKEN_EXPIRES_AT = time.time() + int(data.get("expires_in", 50 * 60))
|
|
95
|
+
log.info("linkedin: refreshed access_token (expires_in=%ss)", data.get("expires_in"))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def reset_token() -> None:
|
|
99
|
+
global _TOKEN, _TOKEN_EXPIRES_AT
|
|
100
|
+
with _LOCK:
|
|
101
|
+
_TOKEN = ""
|
|
102
|
+
_TOKEN_EXPIRES_AT = 0.0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ----- low-level HTTP -----------------------------------------------------
|
|
106
|
+
|
|
107
|
+
def _qs(p: dict[str, Any] | None) -> str:
|
|
108
|
+
"""Build a query string honouring LinkedIn restli encoding rules.
|
|
109
|
+
|
|
110
|
+
Permissive safe set: , : ( ) [ ] % — caller must pre-encode URN
|
|
111
|
+
colons as %3A inside List(...) values.
|
|
112
|
+
"""
|
|
113
|
+
if not p:
|
|
114
|
+
return ""
|
|
115
|
+
parts = [
|
|
116
|
+
f"{quote(str(k), safe='[]')}={quote(str(v), safe=',:()[]%')}"
|
|
117
|
+
for k, v in p.items()
|
|
118
|
+
]
|
|
119
|
+
return "?" + "&".join(parts)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def request(
|
|
123
|
+
method: str,
|
|
124
|
+
path: str,
|
|
125
|
+
*,
|
|
126
|
+
params: dict[str, Any] | None = None,
|
|
127
|
+
json_body: dict | None = None,
|
|
128
|
+
with_version: bool = True,
|
|
129
|
+
timeout: float = 30.0,
|
|
130
|
+
) -> Any:
|
|
131
|
+
"""Signed call to LinkedIn API. Auto-retries once on 401 with a refresh."""
|
|
132
|
+
headers = {
|
|
133
|
+
"Authorization": f"Bearer {get_token()}",
|
|
134
|
+
"X-Restli-Protocol-Version": "2.0.0",
|
|
135
|
+
"Accept": "application/json",
|
|
136
|
+
}
|
|
137
|
+
if with_version:
|
|
138
|
+
headers["LinkedIn-Version"] = _api_version()
|
|
139
|
+
if json_body is not None:
|
|
140
|
+
headers["Content-Type"] = "application/json"
|
|
141
|
+
if isinstance(json_body, dict) and "patch" in json_body:
|
|
142
|
+
headers["X-RestLi-Method"] = "PARTIAL_UPDATE"
|
|
143
|
+
|
|
144
|
+
url = f"{API_HOST}{path}{_qs(params)}"
|
|
145
|
+
|
|
146
|
+
def _do() -> httpx.Response:
|
|
147
|
+
return httpx.request(
|
|
148
|
+
method,
|
|
149
|
+
url,
|
|
150
|
+
headers={**headers, "Authorization": f"Bearer {get_token()}"},
|
|
151
|
+
json=json_body,
|
|
152
|
+
timeout=timeout,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
r = _do()
|
|
156
|
+
if r.status_code == 401:
|
|
157
|
+
with _LOCK:
|
|
158
|
+
_refresh_locked()
|
|
159
|
+
r = _do()
|
|
160
|
+
if r.status_code >= 400:
|
|
161
|
+
raise LinkedInError(f"LinkedIn {method} {path} [{r.status_code}]: {r.text[:300]}")
|
|
162
|
+
if not r.content:
|
|
163
|
+
rid = r.headers.get("x-restli-id") or r.headers.get("x-linkedin-id") or ""
|
|
164
|
+
return {"_id": rid, "_status": r.status_code}
|
|
165
|
+
try:
|
|
166
|
+
body = r.json()
|
|
167
|
+
except json.JSONDecodeError as e:
|
|
168
|
+
raise LinkedInError(f"non-JSON body from {path}: {r.text[:200]}") from e
|
|
169
|
+
rid = r.headers.get("x-restli-id") or r.headers.get("x-linkedin-id")
|
|
170
|
+
if rid and isinstance(body, dict) and "_id" not in body:
|
|
171
|
+
body = {**body, "_id": rid}
|
|
172
|
+
return body
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""LinkedIn Ads MCP — FastMCP server exposing LinkedIn Ads tools.
|
|
2
|
+
|
|
3
|
+
Run:
|
|
4
|
+
$ glitch-grow-linkedin-ad-mcp # stdio, default
|
|
5
|
+
$ glitch-grow-linkedin-ad-mcp --transport sse # SSE on :8000
|
|
6
|
+
$ python -m linkedin_ads_mcp.server # equivalent to first
|
|
7
|
+
|
|
8
|
+
Add to Claude Desktop / Cursor / any MCP client by pointing it at the
|
|
9
|
+
binary. Required env: LINKEDIN_CLIENT_ID, LINKEDIN_CLIENT_SECRET,
|
|
10
|
+
LINKEDIN_REFRESH_TOKEN. Optional: LINKEDIN_ACCESS_TOKEN (seeded),
|
|
11
|
+
LINKEDIN_API_VERSION (default 202604).
|
|
12
|
+
|
|
13
|
+
If you don't want to apply for LinkedIn Marketing API access yourself,
|
|
14
|
+
a hosted app with elevated approval can connect to your account — see README.
|
|
15
|
+
your LinkedIn and we hand you a refresh token scoped to your accounts.
|
|
16
|
+
See README for details.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import time
|
|
22
|
+
from datetime import date, datetime, timedelta, timezone
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from fastmcp import FastMCP
|
|
26
|
+
|
|
27
|
+
from linkedin_ads_mcp.client import LinkedInError, request
|
|
28
|
+
|
|
29
|
+
mcp = FastMCP("glitch-grow-linkedin-ad-mcp")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ---------- helpers --------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
def _start_ms() -> int:
|
|
35
|
+
"""Now + 60s buffer (LinkedIn rejects past timestamps)."""
|
|
36
|
+
return int((time.time() + 60) * 1000)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _date_range_param(days: int) -> dict[str, Any]:
|
|
40
|
+
end = date.today()
|
|
41
|
+
start = end - timedelta(days=days)
|
|
42
|
+
return {
|
|
43
|
+
"dateRange": (
|
|
44
|
+
f"(start:(year:{start.year},month:{start.month},day:{start.day}),"
|
|
45
|
+
f"end:(year:{end.year},month:{end.month},day:{end.day}))"
|
|
46
|
+
),
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _account_urn(account_id: str | int) -> str:
|
|
51
|
+
return f"urn:li:sponsoredAccount:{account_id}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _accounts_list_param(account_id: str | int) -> str:
|
|
55
|
+
# URN colons inside List(...) MUST be %3A-encoded per restli rules.
|
|
56
|
+
return f"List({_account_urn(account_id).replace(':', '%3A')})"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------- read tools -----------------------------------------------------
|
|
60
|
+
|
|
61
|
+
@mcp.tool()
|
|
62
|
+
def list_ad_accounts() -> list[dict]:
|
|
63
|
+
"""List every LinkedIn ad account the OAuth user has any role on.
|
|
64
|
+
|
|
65
|
+
Returns: [{id, urn, name, type, status, currency, serving_statuses}]
|
|
66
|
+
"""
|
|
67
|
+
res = request("GET", "/rest/adAccounts", params={"q": "search"})
|
|
68
|
+
return [
|
|
69
|
+
{
|
|
70
|
+
"id": str(el.get("id", "")),
|
|
71
|
+
"urn": f"urn:li:sponsoredAccount:{el.get('id')}",
|
|
72
|
+
"name": el.get("name", ""),
|
|
73
|
+
"type": el.get("type", ""),
|
|
74
|
+
"status": el.get("status", ""),
|
|
75
|
+
"currency": el.get("currency", ""),
|
|
76
|
+
"serving_statuses": el.get("servingStatuses", []) or [],
|
|
77
|
+
}
|
|
78
|
+
for el in res.get("elements", [])
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@mcp.tool()
|
|
83
|
+
def list_account_users(account_id: str) -> list[dict]:
|
|
84
|
+
"""List user→role assignments on an ad account."""
|
|
85
|
+
res = request(
|
|
86
|
+
"GET", "/rest/adAccountUsers",
|
|
87
|
+
params={"q": "accounts", "accounts": _account_urn(account_id)},
|
|
88
|
+
)
|
|
89
|
+
return [
|
|
90
|
+
{"user": el.get("user", ""), "role": el.get("role", ""), "account": el.get("account", "")}
|
|
91
|
+
for el in res.get("elements", [])
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@mcp.tool()
|
|
96
|
+
def list_campaign_groups(account_id: str) -> list[dict]:
|
|
97
|
+
"""List campaign groups on an ad account."""
|
|
98
|
+
res = request(
|
|
99
|
+
"GET", f"/rest/adAccounts/{account_id}/adCampaignGroups",
|
|
100
|
+
params={"q": "search"},
|
|
101
|
+
)
|
|
102
|
+
return [
|
|
103
|
+
{
|
|
104
|
+
"id": str(el.get("id", "")),
|
|
105
|
+
"name": el.get("name", ""),
|
|
106
|
+
"status": el.get("status", ""),
|
|
107
|
+
"total_budget": (el.get("totalBudget") or {}).get("amount", ""),
|
|
108
|
+
"currency": (el.get("totalBudget") or {}).get("currencyCode", ""),
|
|
109
|
+
}
|
|
110
|
+
for el in res.get("elements", [])
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@mcp.tool()
|
|
115
|
+
def list_campaigns(account_id: str) -> list[dict]:
|
|
116
|
+
"""List all campaigns on an ad account (no metrics — use get_campaign_analytics)."""
|
|
117
|
+
res = request(
|
|
118
|
+
"GET", f"/rest/adAccounts/{account_id}/adCampaigns",
|
|
119
|
+
params={"q": "search"},
|
|
120
|
+
)
|
|
121
|
+
return [
|
|
122
|
+
{
|
|
123
|
+
"id": str(el.get("id", "")),
|
|
124
|
+
"name": el.get("name", ""),
|
|
125
|
+
"status": el.get("status", ""),
|
|
126
|
+
"type": el.get("type", ""),
|
|
127
|
+
"format": el.get("format", ""),
|
|
128
|
+
"objective": el.get("objectiveType", ""),
|
|
129
|
+
"daily_budget": (el.get("dailyBudget") or {}).get("amount", ""),
|
|
130
|
+
"currency": (el.get("dailyBudget") or {}).get("currencyCode", ""),
|
|
131
|
+
"campaign_group": el.get("campaignGroup", ""),
|
|
132
|
+
}
|
|
133
|
+
for el in res.get("elements", [])
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@mcp.tool()
|
|
138
|
+
def list_creatives(account_id: str) -> list[dict]:
|
|
139
|
+
"""List creatives on an ad account."""
|
|
140
|
+
res = request(
|
|
141
|
+
"GET", f"/rest/adAccounts/{account_id}/creatives",
|
|
142
|
+
params={"q": "criteria"},
|
|
143
|
+
)
|
|
144
|
+
return [
|
|
145
|
+
{
|
|
146
|
+
"id": str(el.get("id", "")),
|
|
147
|
+
"status": el.get("status", ""),
|
|
148
|
+
"campaign": el.get("campaign", ""),
|
|
149
|
+
"type": el.get("type", ""),
|
|
150
|
+
}
|
|
151
|
+
for el in res.get("elements", [])
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@mcp.tool()
|
|
156
|
+
def get_account_analytics(account_id: str, days: int = 14) -> dict:
|
|
157
|
+
"""Account-level totals over the last N days (impressions, clicks, costInUsd, conversions)."""
|
|
158
|
+
res = request(
|
|
159
|
+
"GET", "/rest/adAnalytics",
|
|
160
|
+
params={
|
|
161
|
+
"q": "analytics", "pivot": "ACCOUNT", "timeGranularity": "ALL",
|
|
162
|
+
"accounts": _accounts_list_param(account_id),
|
|
163
|
+
"fields": "impressions,clicks,costInUsd,externalWebsiteConversions",
|
|
164
|
+
**_date_range_param(days),
|
|
165
|
+
},
|
|
166
|
+
)
|
|
167
|
+
el = (res.get("elements") or [{}])[0]
|
|
168
|
+
impressions = int(el.get("impressions", 0) or 0)
|
|
169
|
+
clicks = int(el.get("clicks", 0) or 0)
|
|
170
|
+
cost = float(el.get("costInUsd", 0) or 0)
|
|
171
|
+
return {
|
|
172
|
+
"spend_usd": round(cost, 2),
|
|
173
|
+
"clicks": clicks,
|
|
174
|
+
"impressions": impressions,
|
|
175
|
+
"conversions": int(el.get("externalWebsiteConversions", 0) or 0),
|
|
176
|
+
"ctr": round((clicks / impressions) if impressions else 0.0, 4),
|
|
177
|
+
"cpc": round((cost / clicks) if clicks else 0.0, 2),
|
|
178
|
+
"days": days,
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@mcp.tool()
|
|
183
|
+
def get_campaign_analytics(account_id: str, days: int = 14) -> list[dict]:
|
|
184
|
+
"""Per-campaign metrics over the last N days, sorted by spend desc."""
|
|
185
|
+
res = request(
|
|
186
|
+
"GET", "/rest/adAnalytics",
|
|
187
|
+
params={
|
|
188
|
+
"q": "analytics", "pivot": "CAMPAIGN", "timeGranularity": "ALL",
|
|
189
|
+
"accounts": _accounts_list_param(account_id),
|
|
190
|
+
"fields": "pivotValues,impressions,clicks,costInUsd,externalWebsiteConversions",
|
|
191
|
+
**_date_range_param(days),
|
|
192
|
+
},
|
|
193
|
+
)
|
|
194
|
+
out = []
|
|
195
|
+
for row in res.get("elements", []):
|
|
196
|
+
urns = row.get("pivotValues") or []
|
|
197
|
+
urn = urns[0] if urns else ""
|
|
198
|
+
impressions = int(row.get("impressions", 0) or 0)
|
|
199
|
+
clicks = int(row.get("clicks", 0) or 0)
|
|
200
|
+
cost = float(row.get("costInUsd", 0) or 0)
|
|
201
|
+
out.append({
|
|
202
|
+
"campaign_urn": urn,
|
|
203
|
+
"campaign_id": urn.rsplit(":", 1)[-1] if urn else "",
|
|
204
|
+
"spend_usd": round(cost, 2),
|
|
205
|
+
"clicks": clicks,
|
|
206
|
+
"impressions": impressions,
|
|
207
|
+
"conversions": int(row.get("externalWebsiteConversions", 0) or 0),
|
|
208
|
+
"ctr": round((clicks / impressions) if impressions else 0.0, 4),
|
|
209
|
+
"cpc": round((cost / clicks) if clicks else 0.0, 2),
|
|
210
|
+
})
|
|
211
|
+
return sorted(out, key=lambda r: r["spend_usd"], reverse=True)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# ---------- write tools ----------------------------------------------------
|
|
215
|
+
|
|
216
|
+
@mcp.tool()
|
|
217
|
+
def create_campaign_group(
|
|
218
|
+
account_id: str,
|
|
219
|
+
name: str,
|
|
220
|
+
total_budget: float = 100.0,
|
|
221
|
+
currency: str = "USD",
|
|
222
|
+
days: int = 30,
|
|
223
|
+
status: str = "DRAFT",
|
|
224
|
+
) -> dict:
|
|
225
|
+
"""Create a campaign group. Defaults to DRAFT (required precondition for
|
|
226
|
+
creating DRAFT campaigns inside it). Minimum total_budget is $100 USD."""
|
|
227
|
+
start = _start_ms()
|
|
228
|
+
end = start + days * 24 * 3600 * 1000
|
|
229
|
+
body = {
|
|
230
|
+
"account": _account_urn(account_id),
|
|
231
|
+
"name": name,
|
|
232
|
+
"status": status,
|
|
233
|
+
"runSchedule": {"start": start, "end": end},
|
|
234
|
+
"totalBudget": {"currencyCode": currency, "amount": str(total_budget)},
|
|
235
|
+
}
|
|
236
|
+
res = request("POST", f"/rest/adAccounts/{account_id}/adCampaignGroups", json_body=body)
|
|
237
|
+
cg_id = res.get("_id")
|
|
238
|
+
if not cg_id:
|
|
239
|
+
raise LinkedInError(f"campaign-group create returned no id: {res}")
|
|
240
|
+
return {
|
|
241
|
+
"id": str(cg_id),
|
|
242
|
+
"urn": f"urn:li:sponsoredCampaignGroup:{cg_id}",
|
|
243
|
+
"name": name,
|
|
244
|
+
"status": status,
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@mcp.tool()
|
|
249
|
+
def create_campaign(
|
|
250
|
+
account_id: str,
|
|
251
|
+
name: str,
|
|
252
|
+
campaign_group_urn: str,
|
|
253
|
+
daily_budget: float = 10.0,
|
|
254
|
+
unit_cost: float = 10.0,
|
|
255
|
+
currency: str = "USD",
|
|
256
|
+
objective: str = "WEBSITE_TRAFFIC",
|
|
257
|
+
cost_type: str = "CPM",
|
|
258
|
+
type_: str = "TEXT_AD",
|
|
259
|
+
locale_country: str = "US",
|
|
260
|
+
locale_language: str = "en",
|
|
261
|
+
location_geo_urn: str = "urn:li:geo:103644278",
|
|
262
|
+
days: int = 30,
|
|
263
|
+
status: str = "DRAFT",
|
|
264
|
+
) -> dict:
|
|
265
|
+
"""Create a campaign under an existing campaign group.
|
|
266
|
+
|
|
267
|
+
Defaults are demo-safe: DRAFT, $10/day, US/en TEXT_AD with WEBSITE_TRAFFIC
|
|
268
|
+
objective, US-only targeting. Caller must supply `campaign_group_urn`
|
|
269
|
+
(use create_campaign_group first). Status must match the parent group:
|
|
270
|
+
DRAFT/DRAFT or ACTIVE+(PAUSED/ACTIVE).
|
|
271
|
+
"""
|
|
272
|
+
start = _start_ms()
|
|
273
|
+
end = start + days * 24 * 3600 * 1000
|
|
274
|
+
body = {
|
|
275
|
+
"account": _account_urn(account_id),
|
|
276
|
+
"campaignGroup": campaign_group_urn,
|
|
277
|
+
"name": name,
|
|
278
|
+
"status": status,
|
|
279
|
+
"type": type_,
|
|
280
|
+
"objectiveType": objective,
|
|
281
|
+
"costType": cost_type,
|
|
282
|
+
"format": type_,
|
|
283
|
+
"dailyBudget": {"currencyCode": currency, "amount": str(daily_budget)},
|
|
284
|
+
"unitCost": {"currencyCode": currency, "amount": str(unit_cost)},
|
|
285
|
+
"runSchedule": {"start": start, "end": end},
|
|
286
|
+
"locale": {"country": locale_country, "language": locale_language},
|
|
287
|
+
"audienceExpansionEnabled": False,
|
|
288
|
+
"offsiteDeliveryEnabled": False,
|
|
289
|
+
"politicalIntent": "NOT_DECLARED",
|
|
290
|
+
"targetingCriteria": {
|
|
291
|
+
"include": {
|
|
292
|
+
"and": [
|
|
293
|
+
{"or": {"urn:li:adTargetingFacet:locations": [location_geo_urn]}}
|
|
294
|
+
]
|
|
295
|
+
}
|
|
296
|
+
},
|
|
297
|
+
}
|
|
298
|
+
res = request("POST", f"/rest/adAccounts/{account_id}/adCampaigns", json_body=body)
|
|
299
|
+
cid = res.get("_id")
|
|
300
|
+
if not cid:
|
|
301
|
+
raise LinkedInError(f"campaign create returned no id: {res}")
|
|
302
|
+
return {
|
|
303
|
+
"id": str(cid),
|
|
304
|
+
"urn": f"urn:li:sponsoredCampaign:{cid}",
|
|
305
|
+
"name": name,
|
|
306
|
+
"status": status,
|
|
307
|
+
"campaign_group_urn": campaign_group_urn,
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@mcp.tool()
|
|
312
|
+
def update_campaign_status(account_id: str, campaign_id: str, status: str) -> dict:
|
|
313
|
+
"""Flip a campaign's status. Valid: DRAFT, ACTIVE, PAUSED, ARCHIVED."""
|
|
314
|
+
body = {"patch": {"$set": {"status": status}}}
|
|
315
|
+
request("POST", f"/rest/adAccounts/{account_id}/adCampaigns/{campaign_id}", json_body=body)
|
|
316
|
+
return {"id": campaign_id, "new_status": status}
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
@mcp.tool()
|
|
320
|
+
def update_campaign_group_status(account_id: str, group_id: str, status: str) -> dict:
|
|
321
|
+
"""Flip a campaign group's status. Valid: DRAFT, ACTIVE, PAUSED, ARCHIVED, CANCELED."""
|
|
322
|
+
body = {"patch": {"$set": {"status": status}}}
|
|
323
|
+
request("POST", f"/rest/adAccounts/{account_id}/adCampaignGroups/{group_id}", json_body=body)
|
|
324
|
+
return {"id": group_id, "new_status": status}
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
# ---------- entrypoint -----------------------------------------------------
|
|
328
|
+
|
|
329
|
+
def main() -> None:
|
|
330
|
+
parser = argparse.ArgumentParser(description="LinkedIn Ads MCP server")
|
|
331
|
+
parser.add_argument("--transport", choices=["stdio", "sse"], default="stdio")
|
|
332
|
+
parser.add_argument("--host", default="127.0.0.1")
|
|
333
|
+
parser.add_argument("--port", type=int, default=8000)
|
|
334
|
+
args = parser.parse_args()
|
|
335
|
+
if args.transport == "stdio":
|
|
336
|
+
mcp.run()
|
|
337
|
+
else:
|
|
338
|
+
mcp.run(transport="sse", host=args.host, port=args.port)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
if __name__ == "__main__":
|
|
342
|
+
main()
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "linkedin-ads-mcp"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
requires-python = ">=3.10"
|
|
5
|
+
license = "MIT"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"httpx>=0.27",
|
|
8
|
+
"mcp[cli]>=1.2.0",
|
|
9
|
+
"fastmcp>=3.2.0",
|
|
10
|
+
]
|
|
11
|
+
description = "LinkedIn Ads MCP — MCP server for the LinkedIn Marketing API. Read + write campaigns, analytics, and creatives from any MCP client. Maintained by Nuraveda Lab."
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
authors = [
|
|
14
|
+
{ name = "Nuraveda Lab", email = "help.nuraveda@gmail.com" },
|
|
15
|
+
]
|
|
16
|
+
keywords = ["LinkedIn", "LinkedIn Ads", "Marketing API", "MCP", "Model Context Protocol"]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
homepage = "https://github.com/Nuraveda-Labs/linkedin-ads-mcp"
|
|
29
|
+
repository = "https://github.com/Nuraveda-Labs/linkedin-ads-mcp.git"
|
|
30
|
+
issues = "https://github.com/Nuraveda-Labs/linkedin-ads-mcp/issues"
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
linkedin-ads-mcp = "linkedin_ads_mcp.server:main"
|
|
34
|
+
|
|
35
|
+
[project.optional-dependencies]
|
|
36
|
+
dev = ["pytest>=8.0", "ruff>=0.5"]
|
|
37
|
+
|
|
38
|
+
[build-system]
|
|
39
|
+
requires = ["hatchling"]
|
|
40
|
+
build-backend = "hatchling.build"
|
|
41
|
+
|
|
42
|
+
[tool.hatch.build.targets.wheel]
|
|
43
|
+
packages = ["linkedin_ads_mcp"]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
+
"name": "io.github.nuraveda-labs/linkedin-ads-mcp",
|
|
4
|
+
"description": "MCP server for the LinkedIn Marketing API \u2014 campaigns, analytics, creatives.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"url": "https://github.com/Nuraveda-Labs/linkedin-ads-mcp",
|
|
7
|
+
"source": "github"
|
|
8
|
+
},
|
|
9
|
+
"version": "0.2.0",
|
|
10
|
+
"packages": [
|
|
11
|
+
{
|
|
12
|
+
"registryType": "pypi",
|
|
13
|
+
"identifier": "linkedin-ads-mcp",
|
|
14
|
+
"version": "0.2.0",
|
|
15
|
+
"transport": {
|
|
16
|
+
"type": "stdio"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Smoke tests — import + tool registration. No live API calls."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def test_import_server():
|
|
6
|
+
from linkedin_ads_mcp import server
|
|
7
|
+
assert server.mcp is not None
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_tools_registered():
|
|
11
|
+
import asyncio
|
|
12
|
+
|
|
13
|
+
from linkedin_ads_mcp.server import mcp
|
|
14
|
+
tools = asyncio.run(mcp.list_tools())
|
|
15
|
+
names = {t.name for t in tools}
|
|
16
|
+
expected = {
|
|
17
|
+
"list_ad_accounts", "list_account_users",
|
|
18
|
+
"list_campaign_groups", "list_campaigns", "list_creatives",
|
|
19
|
+
"get_account_analytics", "get_campaign_analytics",
|
|
20
|
+
"create_campaign_group", "create_campaign",
|
|
21
|
+
"update_campaign_status", "update_campaign_group_status",
|
|
22
|
+
}
|
|
23
|
+
missing = expected - names
|
|
24
|
+
assert not missing, f"missing tools: {missing}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_qs_encoding():
|
|
28
|
+
from linkedin_ads_mcp.client import _qs
|
|
29
|
+
# restli rules: literal commas in fields, %3A in URN, literal colons in tuples
|
|
30
|
+
qs = _qs({
|
|
31
|
+
"fields": "impressions,clicks",
|
|
32
|
+
"accounts": "List(urn%3Ali%3AsponsoredAccount%3A1)",
|
|
33
|
+
"dateRange": "(start:(year:2026,month:1,day:1),end:(year:2026,month:1,day:31))",
|
|
34
|
+
})
|
|
35
|
+
assert "fields=impressions,clicks" in qs
|
|
36
|
+
assert "List(urn%3Ali%3AsponsoredAccount%3A1)" in qs
|
|
37
|
+
assert "(start:(year:2026,month:1,day:1)" in qs
|