mcp-linkedin-ads 1.0.13 → 1.1.1

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/README.md CHANGED
@@ -64,6 +64,26 @@ npm install mcp-linkedin-ads
64
64
  export LINKEDIN_ADS_ACCESS_TOKEN="your_access_token"
65
65
  ```
66
66
 
67
+ ### Environment variables
68
+
69
+ | Variable | Required | Default | Purpose |
70
+ |---|---|---|---|
71
+ | `LINKEDIN_ADS_CLIENT_ID` | yes | -- | OAuth client ID |
72
+ | `LINKEDIN_ADS_CLIENT_SECRET` | yes | -- | OAuth client secret |
73
+ | `LINKEDIN_ADS_ACCESS_TOKEN` | yes | -- | OAuth access token |
74
+ | `LINKEDIN_ADS_REFRESH_TOKEN` | optional | -- | OAuth refresh token (rotated automatically when set) |
75
+ | `LINKEDIN_ADS_MCP_WRITE` | optional | `false` | Set to `true` to expose mutating tools. Read-only by default. |
76
+
77
+ ### Read-only by default
78
+
79
+ The LinkedIn Ads MCP currently ships with read-only tools only. The write-mode
80
+ gate is already in place so that any future create/update/pause/enable/remove
81
+ tool is hidden from `ListTools` and refused at call time unless
82
+ `LINKEDIN_ADS_MCP_WRITE=true` is set in the MCP server environment. This
83
+ mirrors the Google Ads MCP gate and matches the pattern being rolled out to
84
+ Bing / Reddit / Meta. Motivation: prevent a casual LLM request from mutating
85
+ production ad accounts without the operator explicitly opting in.
86
+
67
87
  ## Usage
68
88
 
69
89
  ### Start the server
@@ -1 +1 @@
1
- {"sha":"3375b28","builtAt":"2026-04-09T23:02:26.033Z"}
1
+ {"sha":"acbfeb9","builtAt":"2026-04-18T18:10:49.640Z"}
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import { readFileSync, existsSync } from "fs";
6
6
  import { join, dirname } from "path";
7
7
  import { LinkedInAdsAuthError, classifyError, validateCredentials, } from "./errors.js";
8
8
  import { tools } from "./tools.js";
9
+ import { filterTools, assertWriteAllowed, isWriteEnabled } from "./writeGate.js";
9
10
  import { withResilience, safeResponse, logger } from "./resilience.js";
10
11
  import v8 from "v8";
11
12
  // CLI package info
@@ -21,7 +22,13 @@ catch {
21
22
  }
22
23
  // Version safety: warn if running a deprecated or dangerously old version
23
24
  const __minimumSafeVersion = "1.0.5"; // minimum version with input sanitization
24
- if (__cliPkg.version < __minimumSafeVersion) {
25
+ const __semverLt = (a, b) => { const pa = a.split(".").map(Number), pb = b.split(".").map(Number); for (let i = 0; i < 3; i++) {
26
+ if ((pa[i] || 0) < (pb[i] || 0))
27
+ return true;
28
+ if ((pa[i] || 0) > (pb[i] || 0))
29
+ return false;
30
+ } return false; };
31
+ if (__semverLt(__cliPkg.version, __minimumSafeVersion)) {
25
32
  console.error(`[WARNING] Running deprecated version ${__cliPkg.version}. Minimum safe version is ${__minimumSafeVersion}. Please upgrade.`);
26
33
  }
27
34
  // CLI flags
@@ -55,7 +62,9 @@ const envTrimmed = (key) => (process.env[key] || "").trim().replace(/^["']|["']$
55
62
  function loadConfig() {
56
63
  const configPath = join(dirname(new URL(import.meta.url).pathname), "..", "config.json");
57
64
  if (!existsSync(configPath)) {
58
- throw new Error(`Config file not found at ${configPath}. Create config.json from config.example.json with your client entries.`);
65
+ throw new Error(`Config file not found at ${configPath}. Create config.json from config.example.json with your client entries, ` +
66
+ `or set env vars LINKEDIN_ACCESS_TOKEN, LINKEDIN_ADS_REFRESH_TOKEN, linkedin-client-id, and linkedin-client-secret. ` +
67
+ `Run 'node get-refresh-token.cjs' to obtain a refresh token.`);
59
68
  }
60
69
  return JSON.parse(readFileSync(configPath, "utf-8"));
61
70
  }
@@ -298,12 +307,13 @@ const server = new Server({
298
307
  });
299
308
  // Handle list tools
300
309
  server.setRequestHandler(ListToolsRequestSchema, async () => {
301
- return { tools };
310
+ return { tools: filterTools(tools) };
302
311
  });
303
312
  // Handle tool calls
304
313
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
305
314
  const { name, arguments: args } = request.params;
306
315
  try {
316
+ assertWriteAllowed(name);
307
317
  const resolveAccountId = (accountId) => {
308
318
  if (accountId)
309
319
  return accountId;
@@ -435,6 +445,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
435
445
  async function main() {
436
446
  const transport = new StdioServerTransport();
437
447
  await server.connect(transport);
448
+ const writeMode = isWriteEnabled()
449
+ ? "WRITES ENABLED (LINKEDIN_ADS_MCP_WRITE=true)"
450
+ : "read-only (set LINKEDIN_ADS_MCP_WRITE=true to enable mutating tools)";
451
+ console.error(`[startup] write mode: ${writeMode}`);
438
452
  logger.info("MCP LinkedIn Ads server running");
439
453
  }
440
454
  process.on("SIGTERM", () => {
@@ -0,0 +1,7 @@
1
+ export type FetchLatestVersion = () => Promise<string>;
2
+ export interface UpdateNotifierDeps {
3
+ fetchLatestVersion?: FetchLatestVersion;
4
+ log?: (msg: string) => void;
5
+ env?: NodeJS.ProcessEnv;
6
+ }
7
+ export declare function checkForUpdate(pkgName: string, currentVersion: string, deps?: UpdateNotifierDeps): Promise<void>;
@@ -0,0 +1,59 @@
1
+ // Fire-and-forget npm registry check at startup. Logs to stderr when a
2
+ // newer version is available. stdout is reserved for MCP JSON-RPC, so the
3
+ // message never goes there. Silent on network error, timeout, or when the
4
+ // installed version is equal to or ahead of the registry latest.
5
+ //
6
+ // Opt out by setting MCP_DISABLE_UPDATE_CHECK=1 (CI, offline, air-gapped).
7
+ // Also skipped when NODE_ENV=test to keep vitest runs silent.
8
+ export async function checkForUpdate(pkgName, currentVersion, deps = {}) {
9
+ const env = deps.env ?? process.env;
10
+ if (env.MCP_DISABLE_UPDATE_CHECK === "1" || env.NODE_ENV === "test") {
11
+ return;
12
+ }
13
+ const log = deps.log ?? ((msg) => process.stderr.write(msg + "\n"));
14
+ const fetcher = deps.fetchLatestVersion ?? (() => defaultFetch(pkgName));
15
+ let latest;
16
+ try {
17
+ latest = await fetcher();
18
+ }
19
+ catch {
20
+ return;
21
+ }
22
+ if (!latest || !semverLt(currentVersion, latest)) {
23
+ return;
24
+ }
25
+ log(`[update] ${pkgName}@${latest} is available (running ${currentVersion}). ` +
26
+ `Upgrade: npx -y ${pkgName}@latest (and relaunch Claude Desktop).`);
27
+ }
28
+ async function defaultFetch(pkgName) {
29
+ const controller = new AbortController();
30
+ const timer = setTimeout(() => controller.abort(), 2_000);
31
+ try {
32
+ const res = await fetch(`https://registry.npmjs.org/${pkgName}/latest`, {
33
+ signal: controller.signal,
34
+ headers: { Accept: "application/json" },
35
+ });
36
+ if (!res.ok) {
37
+ throw new Error(`HTTP ${res.status}`);
38
+ }
39
+ const body = (await res.json());
40
+ if (typeof body.version !== "string") {
41
+ throw new Error("registry response missing version field");
42
+ }
43
+ return body.version;
44
+ }
45
+ finally {
46
+ clearTimeout(timer);
47
+ }
48
+ }
49
+ function semverLt(a, b) {
50
+ const pa = a.split(".").map((x) => parseInt(x, 10) || 0);
51
+ const pb = b.split(".").map((x) => parseInt(x, 10) || 0);
52
+ for (let i = 0; i < 3; i++) {
53
+ if ((pa[i] ?? 0) < (pb[i] ?? 0))
54
+ return true;
55
+ if ((pa[i] ?? 0) > (pb[i] ?? 0))
56
+ return false;
57
+ }
58
+ return false;
59
+ }
@@ -0,0 +1,22 @@
1
+ import type { Tool } from "@modelcontextprotocol/sdk/types.js";
2
+ /**
3
+ * Tools that mutate LinkedIn Ads state. These are hidden from the tool list
4
+ * and refused at call time unless LINKEDIN_ADS_MCP_WRITE=true.
5
+ *
6
+ * Adding a new tool? Put it in this set if it creates, modifies, pauses,
7
+ * enables, removes, links, unlinks, or applies anything.
8
+ *
9
+ * This set is currently empty: the LinkedIn Ads MCP exposes read-only tools
10
+ * only. The gate still ships so that any future write tool is gated by
11
+ * default, matching the Google Ads / Bing / Reddit / Meta pattern.
12
+ */
13
+ export declare const WRITE_TOOLS: ReadonlySet<string>;
14
+ export declare function isWriteTool(name: string): boolean;
15
+ export declare function isWriteEnabled(env?: NodeJS.ProcessEnv): boolean;
16
+ export declare function filterTools(allTools: readonly Tool[], env?: NodeJS.ProcessEnv): Tool[];
17
+ export declare const WRITE_DISABLED_MESSAGE = "Write operations are disabled. Set LINKEDIN_ADS_MCP_WRITE=true in the MCP server environment to enable mutating tools (create/update/pause/enable/remove/apply).";
18
+ /**
19
+ * Assert that a tool call is allowed under the current write-mode setting.
20
+ * Throws a clear Error if the tool mutates state and writes are disabled.
21
+ */
22
+ export declare function assertWriteAllowed(toolName: string, env?: NodeJS.ProcessEnv): void;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Tools that mutate LinkedIn Ads state. These are hidden from the tool list
3
+ * and refused at call time unless LINKEDIN_ADS_MCP_WRITE=true.
4
+ *
5
+ * Adding a new tool? Put it in this set if it creates, modifies, pauses,
6
+ * enables, removes, links, unlinks, or applies anything.
7
+ *
8
+ * This set is currently empty: the LinkedIn Ads MCP exposes read-only tools
9
+ * only. The gate still ships so that any future write tool is gated by
10
+ * default, matching the Google Ads / Bing / Reddit / Meta pattern.
11
+ */
12
+ export const WRITE_TOOLS = new Set([]);
13
+ export function isWriteTool(name) {
14
+ return WRITE_TOOLS.has(name);
15
+ }
16
+ export function isWriteEnabled(env = process.env) {
17
+ const v = (env.LINKEDIN_ADS_MCP_WRITE || "").trim().toLowerCase();
18
+ return v === "true" || v === "1" || v === "yes";
19
+ }
20
+ export function filterTools(allTools, env = process.env) {
21
+ if (isWriteEnabled(env))
22
+ return [...allTools];
23
+ return allTools.filter((t) => !WRITE_TOOLS.has(t.name));
24
+ }
25
+ export const WRITE_DISABLED_MESSAGE = "Write operations are disabled. Set LINKEDIN_ADS_MCP_WRITE=true in the MCP server environment to enable mutating tools (create/update/pause/enable/remove/apply).";
26
+ /**
27
+ * Assert that a tool call is allowed under the current write-mode setting.
28
+ * Throws a clear Error if the tool mutates state and writes are disabled.
29
+ */
30
+ export function assertWriteAllowed(toolName, env = process.env) {
31
+ if (!isWriteTool(toolName))
32
+ return;
33
+ if (isWriteEnabled(env))
34
+ return;
35
+ throw new Error(`Tool "${toolName}" is a write operation. ${WRITE_DISABLED_MESSAGE}`);
36
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mcp-linkedin-ads",
3
3
  "mcpName": "io.github.mharnett/linkedin-ads",
4
- "version": "1.0.13",
4
+ "version": "1.1.1",
5
5
  "description": "MCP server for LinkedIn Campaign Manager API with full campaign, ad group, creative, and targeting support. Production-proven with 65+ campaigns under active management.",
6
6
  "main": "dist/index.js",
7
7
  "bin": {
@@ -58,6 +58,7 @@
58
58
  "zod": "^3.22.4"
59
59
  },
60
60
  "devDependencies": {
61
+ "@drak-marketing/mcp-test-harness": "^0.1.2",
61
62
  "@types/node": "^20.10.0",
62
63
  "tsx": "^4.7.0",
63
64
  "typescript": "^5.3.0",