mcp-linkedin-ads 1.0.14 → 1.1.2
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 +20 -0
- package/dist/build-info.json +1 -1
- package/dist/index.js +42 -9
- package/dist/tools.js +3 -3
- package/dist/updateNotifier.d.ts +7 -0
- package/dist/updateNotifier.js +59 -0
- package/dist/writeGate.d.ts +22 -0
- package/dist/writeGate.js +36 -0
- package/package.json +4 -2
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
|
package/dist/build-info.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"sha":"
|
|
1
|
+
{"sha":"eb446cf","builtAt":"2026-05-08T18:24:15.904Z"}
|
package/dist/index.js
CHANGED
|
@@ -4,16 +4,18 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
4
4
|
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
5
5
|
import { readFileSync, existsSync } from "fs";
|
|
6
6
|
import { join, dirname } from "path";
|
|
7
|
+
import { fileURLToPath } from "url";
|
|
8
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
9
|
import { LinkedInAdsAuthError, classifyError, validateCredentials, } from "./errors.js";
|
|
8
10
|
import { tools } from "./tools.js";
|
|
11
|
+
import { filterTools, assertWriteAllowed, isWriteEnabled } from "./writeGate.js";
|
|
9
12
|
import { withResilience, safeResponse, logger } from "./resilience.js";
|
|
10
13
|
import v8 from "v8";
|
|
11
14
|
// CLI package info
|
|
12
|
-
const __cliPkg = JSON.parse(readFileSync(join(
|
|
15
|
+
const __cliPkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
|
|
13
16
|
// Log build fingerprint at startup
|
|
14
17
|
try {
|
|
15
|
-
const
|
|
16
|
-
const buildInfo = JSON.parse(readFileSync(join(__buildInfoDir, "build-info.json"), "utf-8"));
|
|
18
|
+
const buildInfo = JSON.parse(readFileSync(join(__dirname, "build-info.json"), "utf-8"));
|
|
17
19
|
console.error(`[build] SHA: ${buildInfo.sha} (${buildInfo.builtAt})`);
|
|
18
20
|
}
|
|
19
21
|
catch {
|
|
@@ -59,9 +61,11 @@ if (heapLimit < 256 * 1024 * 1024) {
|
|
|
59
61
|
// ============================================
|
|
60
62
|
const envTrimmed = (key) => (process.env[key] || "").trim().replace(/^["']|["']$/g, "");
|
|
61
63
|
function loadConfig() {
|
|
62
|
-
const configPath = join(
|
|
64
|
+
const configPath = join(__dirname, "..", "config.json");
|
|
63
65
|
if (!existsSync(configPath)) {
|
|
64
|
-
throw new Error(`Config file not found at ${configPath}. Create config.json from config.example.json with your client entries
|
|
66
|
+
throw new Error(`Config file not found at ${configPath}. Create config.json from config.example.json with your client entries, ` +
|
|
67
|
+
`or set env vars LINKEDIN_ACCESS_TOKEN, LINKEDIN_ADS_REFRESH_TOKEN, linkedin-client-id, and linkedin-client-secret. ` +
|
|
68
|
+
`Run 'node get-refresh-token.cjs' to obtain a refresh token.`);
|
|
65
69
|
}
|
|
66
70
|
return JSON.parse(readFileSync(configPath, "utf-8"));
|
|
67
71
|
}
|
|
@@ -73,6 +77,23 @@ function getClientFromWorkingDir(config, cwd) {
|
|
|
73
77
|
}
|
|
74
78
|
return null;
|
|
75
79
|
}
|
|
80
|
+
// Add derived `frequency` (impressions / approximateMemberReach) to each
|
|
81
|
+
// analytics element when both fields are present. approximateMemberReach is
|
|
82
|
+
// only populated by the LinkedIn API at certain pivots (CAMPAIGN, CREATIVE,
|
|
83
|
+
// CAMPAIGN_GROUP); at pivot=ACCOUNT it returns 0, in which case frequency is
|
|
84
|
+
// omitted rather than reported as Infinity.
|
|
85
|
+
function enrichAnalyticsResponse(raw) {
|
|
86
|
+
if (!raw || !Array.isArray(raw.elements))
|
|
87
|
+
return raw;
|
|
88
|
+
for (const el of raw.elements) {
|
|
89
|
+
const reach = Number(el?.approximateMemberReach ?? 0);
|
|
90
|
+
const impr = Number(el?.impressions ?? 0);
|
|
91
|
+
if (reach > 0 && impr > 0) {
|
|
92
|
+
el.frequency = +(impr / reach).toFixed(4);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return raw;
|
|
96
|
+
}
|
|
76
97
|
// ============================================
|
|
77
98
|
// LINKEDIN MARKETING API CLIENT
|
|
78
99
|
// ============================================
|
|
@@ -222,7 +243,7 @@ class LinkedInAdsManager {
|
|
|
222
243
|
const statuses = options?.status || ["ACTIVE", "PAUSED"];
|
|
223
244
|
const statusList = `List(${statuses.join(",")})`;
|
|
224
245
|
let searchParams = `(status:(values:${statusList}))`;
|
|
225
|
-
let url = `${this.config.api.base_url}/adAccounts/${accountId}/adCampaigns?q=search&search=${
|
|
246
|
+
let url = `${this.config.api.base_url}/adAccounts/${accountId}/adCampaigns?q=search&search=${searchParams}&count=100`;
|
|
226
247
|
if (options?.campaignGroupId) {
|
|
227
248
|
url += `&search.campaignGroup.values=List(urn%3Ali%3AsponsoredCampaignGroup%3A${options.campaignGroupId})`;
|
|
228
249
|
}
|
|
@@ -244,12 +265,18 @@ class LinkedInAdsManager {
|
|
|
244
265
|
"oneClickLeads", "oneClickLeadFormOpens",
|
|
245
266
|
"externalWebsiteConversions", "externalWebsitePostClickConversions",
|
|
246
267
|
"totalEngagements", "videoViews", "videoCompletions",
|
|
268
|
+
"approximateMemberReach",
|
|
247
269
|
"dateRange", "pivotValues",
|
|
248
270
|
];
|
|
271
|
+
// LinkedIn Restli quirk (verified against /rest/adAnalytics with
|
|
272
|
+
// LinkedIn-Version 202602): dateRange value must use literal
|
|
273
|
+
// parens/colons/commas — percent-encoding them returns 400 PARAM_INVALID.
|
|
274
|
+
// The URN inside accounts=List(...) MUST be percent-encoded, but the
|
|
275
|
+
// List() wrapper itself stays literal. fields uses literal commas.
|
|
249
276
|
const accountUrn = encodeURIComponent(`urn:li:sponsoredAccount:${options.accountId}`);
|
|
250
277
|
let url = `${this.config.api.base_url}/adAnalytics?q=analytics` +
|
|
251
278
|
`&pivot=${options.pivot}` +
|
|
252
|
-
`&dateRange=${
|
|
279
|
+
`&dateRange=${dateRange}` +
|
|
253
280
|
`&timeGranularity=${granularity}` +
|
|
254
281
|
`&accounts=List(${accountUrn})` +
|
|
255
282
|
`&fields=${fields.join(",")}`;
|
|
@@ -265,7 +292,8 @@ class LinkedInAdsManager {
|
|
|
265
292
|
.join(",");
|
|
266
293
|
url += `&campaignGroups=List(${urns})`;
|
|
267
294
|
}
|
|
268
|
-
|
|
295
|
+
const raw = await this.apiGetRaw(url);
|
|
296
|
+
return enrichAnalyticsResponse(raw);
|
|
269
297
|
}
|
|
270
298
|
async getCampaignPerformance(accountId, options) {
|
|
271
299
|
return await this.getAnalytics({
|
|
@@ -304,12 +332,13 @@ const server = new Server({
|
|
|
304
332
|
});
|
|
305
333
|
// Handle list tools
|
|
306
334
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
307
|
-
return { tools };
|
|
335
|
+
return { tools: filterTools(tools) };
|
|
308
336
|
});
|
|
309
337
|
// Handle tool calls
|
|
310
338
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
311
339
|
const { name, arguments: args } = request.params;
|
|
312
340
|
try {
|
|
341
|
+
assertWriteAllowed(name);
|
|
313
342
|
const resolveAccountId = (accountId) => {
|
|
314
343
|
if (accountId)
|
|
315
344
|
return accountId;
|
|
@@ -441,6 +470,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
441
470
|
async function main() {
|
|
442
471
|
const transport = new StdioServerTransport();
|
|
443
472
|
await server.connect(transport);
|
|
473
|
+
const writeMode = isWriteEnabled()
|
|
474
|
+
? "WRITES ENABLED (LINKEDIN_ADS_MCP_WRITE=true)"
|
|
475
|
+
: "read-only (set LINKEDIN_ADS_MCP_WRITE=true to enable mutating tools)";
|
|
476
|
+
console.error(`[startup] write mode: ${writeMode}`);
|
|
444
477
|
logger.info("MCP LinkedIn Ads server running");
|
|
445
478
|
}
|
|
446
479
|
process.on("SIGTERM", () => {
|
package/dist/tools.js
CHANGED
|
@@ -56,7 +56,7 @@ export const tools = [
|
|
|
56
56
|
},
|
|
57
57
|
{
|
|
58
58
|
name: "linkedin_ads_campaign_performance",
|
|
59
|
-
description: "Get campaign-level performance metrics (impressions, clicks, spend, conversions, leads, engagement, video views) for a date range. This is the main reporting tool for weekly slides.",
|
|
59
|
+
description: "Get campaign-level performance metrics (impressions, clicks, spend, conversions, leads, engagement, video views, reach, frequency) for a date range. Reach = approximateMemberReach (unique members exposed); frequency = impressions / reach (computed server-side). This is the main reporting tool for weekly slides.",
|
|
60
60
|
inputSchema: {
|
|
61
61
|
additionalProperties: false,
|
|
62
62
|
type: "object",
|
|
@@ -71,7 +71,7 @@ export const tools = [
|
|
|
71
71
|
},
|
|
72
72
|
{
|
|
73
73
|
name: "linkedin_ads_account_performance",
|
|
74
|
-
description: "Get account-level aggregate performance metrics for a date range. Good for high-level summaries.",
|
|
74
|
+
description: "Get account-level aggregate performance metrics for a date range. Good for high-level summaries. Note: reach (approximateMemberReach) and the derived frequency field are not populated at pivot=ACCOUNT; query at CAMPAIGN/CAMPAIGN_GROUP via linkedin_ads_campaign_performance or linkedin_ads_analytics if you need reach.",
|
|
75
75
|
inputSchema: {
|
|
76
76
|
additionalProperties: false,
|
|
77
77
|
type: "object",
|
|
@@ -102,7 +102,7 @@ export const tools = [
|
|
|
102
102
|
fields: {
|
|
103
103
|
type: "array",
|
|
104
104
|
items: { type: "string" },
|
|
105
|
-
description: "Metrics to return. Default: impressions, clicks, costInLocalCurrency, landingPageClicks, oneClickLeads, externalWebsiteConversions, totalEngagements, videoViews, dateRange, pivotValues",
|
|
105
|
+
description: "Metrics to return. Default: impressions, clicks, costInLocalCurrency, landingPageClicks, oneClickLeads, oneClickLeadFormOpens, externalWebsiteConversions, externalWebsitePostClickConversions, totalEngagements, videoViews, videoCompletions, approximateMemberReach, dateRange, pivotValues. The server adds a derived `frequency` field (impressions / approximateMemberReach) when both are non-zero. Reach is only populated at pivots CAMPAIGN, CREATIVE, and CAMPAIGN_GROUP — it returns 0 at pivot=ACCOUNT.",
|
|
106
106
|
},
|
|
107
107
|
campaign_ids: { type: "array", items: { type: "string" }, description: "Filter by numeric string campaign IDs" },
|
|
108
108
|
campaign_group_ids: { type: "array", items: { type: "string" }, description: "Filter by campaign group IDs" },
|
|
@@ -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.
|
|
4
|
+
"version": "1.1.2",
|
|
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": {
|
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
"build": "tsc && node -e \"fs=require('fs');cp=require('child_process');sha=cp.execSync('git rev-parse --short HEAD 2>/dev/null||echo unknown').toString().trim();fs.writeFileSync('dist/build-info.json',JSON.stringify({sha,builtAt:new Date().toISOString()}))\"",
|
|
26
26
|
"start": "node dist/index.js",
|
|
27
27
|
"dev": "tsx src/index.ts",
|
|
28
|
-
"test": "vitest run"
|
|
28
|
+
"test": "vitest run",
|
|
29
|
+
"smoke": "scripts/healthcheck.sh"
|
|
29
30
|
},
|
|
30
31
|
"keywords": [
|
|
31
32
|
"mcp",
|
|
@@ -58,6 +59,7 @@
|
|
|
58
59
|
"zod": "^3.22.4"
|
|
59
60
|
},
|
|
60
61
|
"devDependencies": {
|
|
62
|
+
"@drak-marketing/mcp-test-harness": "^0.1.2",
|
|
61
63
|
"@types/node": "^20.10.0",
|
|
62
64
|
"tsx": "^4.7.0",
|
|
63
65
|
"typescript": "^5.3.0",
|