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.
@@ -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,9 @@
1
+ .env
2
+ .venv/
3
+ __pycache__/
4
+ *.pyc
5
+ *.egg-info/
6
+ dist/
7
+ build/
8
+ .pytest_cache/
9
+ .ruff_cache/
@@ -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
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/)
33
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
34
+ [![Part of Mesh Pilot](https://img.shields.io/badge/Mesh%20Pilot-OSS%20tooling-7c3aed.svg)](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
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
7
+ [![Part of Mesh Pilot](https://img.shields.io/badge/Mesh%20Pilot-OSS%20tooling-7c3aed.svg)](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