mobile-growth-mcp 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1088 -0
- package/package.json +36 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1088 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
|
|
7
|
+
// src/remote-proxy.ts
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
var EDGE_FUNCTION_URL = "https://iattgvzqiqrpzoqnrwfr.supabase.co/functions/v1/mcp";
|
|
10
|
+
var nextRequestId = 1;
|
|
11
|
+
async function jsonRpcRequest(apiKey2, method, params) {
|
|
12
|
+
const body = {
|
|
13
|
+
jsonrpc: "2.0",
|
|
14
|
+
method,
|
|
15
|
+
id: nextRequestId++
|
|
16
|
+
};
|
|
17
|
+
if (params) body.params = params;
|
|
18
|
+
const res = await fetch(EDGE_FUNCTION_URL, {
|
|
19
|
+
method: "POST",
|
|
20
|
+
headers: {
|
|
21
|
+
"Content-Type": "application/json",
|
|
22
|
+
"x-api-key": apiKey2,
|
|
23
|
+
Accept: "application/json"
|
|
24
|
+
},
|
|
25
|
+
body: JSON.stringify(body),
|
|
26
|
+
signal: AbortSignal.timeout(15e3)
|
|
27
|
+
});
|
|
28
|
+
if (!res.ok) {
|
|
29
|
+
const text = await res.text();
|
|
30
|
+
throw new Error(`Edge Function error (${res.status}): ${text}`);
|
|
31
|
+
}
|
|
32
|
+
return await res.json();
|
|
33
|
+
}
|
|
34
|
+
async function fetchRemoteTools(apiKey2) {
|
|
35
|
+
const resp = await jsonRpcRequest(apiKey2, "tools/list");
|
|
36
|
+
if (resp.error) {
|
|
37
|
+
throw new Error(`tools/list error: ${resp.error.message}`);
|
|
38
|
+
}
|
|
39
|
+
return resp.result?.tools ?? [];
|
|
40
|
+
}
|
|
41
|
+
async function callRemoteTool(apiKey2, name, args) {
|
|
42
|
+
const resp = await jsonRpcRequest(apiKey2, "tools/call", {
|
|
43
|
+
name,
|
|
44
|
+
arguments: args
|
|
45
|
+
});
|
|
46
|
+
if (resp.error) {
|
|
47
|
+
return {
|
|
48
|
+
content: [{ type: "text", text: `Remote error: ${resp.error.message}` }],
|
|
49
|
+
isError: true
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
content: resp.result?.content ?? [{ type: "text", text: "No content returned" }],
|
|
54
|
+
isError: resp.result?.isError
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function jsonSchemaToZodShape(inputSchema) {
|
|
58
|
+
const properties = inputSchema.properties ?? {};
|
|
59
|
+
const required = new Set(inputSchema.required ?? []);
|
|
60
|
+
const shape = {};
|
|
61
|
+
for (const [key, prop] of Object.entries(properties)) {
|
|
62
|
+
let field;
|
|
63
|
+
if (prop.oneOf) {
|
|
64
|
+
field = z.union([z.number(), z.string()]);
|
|
65
|
+
} else if (prop.type === "string") {
|
|
66
|
+
field = z.string();
|
|
67
|
+
} else if (prop.type === "number") {
|
|
68
|
+
field = z.number();
|
|
69
|
+
} else if (prop.type === "boolean") {
|
|
70
|
+
field = z.boolean();
|
|
71
|
+
} else if (prop.type === "array") {
|
|
72
|
+
field = z.array(z.string());
|
|
73
|
+
} else {
|
|
74
|
+
field = z.any();
|
|
75
|
+
}
|
|
76
|
+
if (prop.description) {
|
|
77
|
+
field = field.describe(prop.description);
|
|
78
|
+
}
|
|
79
|
+
if (!required.has(key)) {
|
|
80
|
+
field = field.optional();
|
|
81
|
+
}
|
|
82
|
+
shape[key] = field;
|
|
83
|
+
}
|
|
84
|
+
return shape;
|
|
85
|
+
}
|
|
86
|
+
async function registerRemoteTools(server2, apiKey2) {
|
|
87
|
+
let tools;
|
|
88
|
+
try {
|
|
89
|
+
tools = await fetchRemoteTools(apiKey2);
|
|
90
|
+
} catch (err) {
|
|
91
|
+
console.error(
|
|
92
|
+
`Failed to fetch remote tools: ${err.message}. KB tools will not be available.`
|
|
93
|
+
);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
for (const tool of tools) {
|
|
97
|
+
const zodShape = jsonSchemaToZodShape(tool.inputSchema);
|
|
98
|
+
server2.tool(tool.name, tool.description, zodShape, async (args) => {
|
|
99
|
+
const result = await callRemoteTool(apiKey2, tool.name, args);
|
|
100
|
+
return {
|
|
101
|
+
content: result.content.map((c) => ({
|
|
102
|
+
type: "text",
|
|
103
|
+
text: c.text
|
|
104
|
+
})),
|
|
105
|
+
isError: result.isError
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
async function fetchRemotePrompts(apiKey2) {
|
|
111
|
+
const resp = await jsonRpcRequest(apiKey2, "prompts/list");
|
|
112
|
+
if (resp.error) {
|
|
113
|
+
throw new Error(`prompts/list error: ${resp.error.message}`);
|
|
114
|
+
}
|
|
115
|
+
return resp.result?.prompts ?? [];
|
|
116
|
+
}
|
|
117
|
+
async function getRemotePrompt(apiKey2, name, args) {
|
|
118
|
+
const resp = await jsonRpcRequest(apiKey2, "prompts/get", {
|
|
119
|
+
name,
|
|
120
|
+
arguments: args
|
|
121
|
+
});
|
|
122
|
+
if (resp.error) {
|
|
123
|
+
throw new Error(`prompts/get error: ${resp.error.message}`);
|
|
124
|
+
}
|
|
125
|
+
return resp.result?.messages ?? [];
|
|
126
|
+
}
|
|
127
|
+
async function registerRemotePrompts(server2, apiKey2) {
|
|
128
|
+
let remotePrompts;
|
|
129
|
+
try {
|
|
130
|
+
remotePrompts = await fetchRemotePrompts(apiKey2);
|
|
131
|
+
} catch (err) {
|
|
132
|
+
console.error(
|
|
133
|
+
`Failed to fetch remote prompts: ${err.message}. Prompts will not be available.`
|
|
134
|
+
);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
for (const prompt of remotePrompts) {
|
|
138
|
+
const zodShape = {};
|
|
139
|
+
for (const arg of prompt.arguments) {
|
|
140
|
+
let field = z.string().describe(arg.description);
|
|
141
|
+
if (!arg.required) {
|
|
142
|
+
field = field.optional();
|
|
143
|
+
}
|
|
144
|
+
zodShape[arg.name] = field;
|
|
145
|
+
}
|
|
146
|
+
server2.prompt(
|
|
147
|
+
prompt.name,
|
|
148
|
+
prompt.description,
|
|
149
|
+
zodShape,
|
|
150
|
+
async (args) => {
|
|
151
|
+
const messages = await getRemotePrompt(
|
|
152
|
+
apiKey2,
|
|
153
|
+
prompt.name,
|
|
154
|
+
args
|
|
155
|
+
);
|
|
156
|
+
return {
|
|
157
|
+
messages: messages.map((m) => ({
|
|
158
|
+
role: m.role,
|
|
159
|
+
content: {
|
|
160
|
+
type: "text",
|
|
161
|
+
text: m.content.text
|
|
162
|
+
}
|
|
163
|
+
}))
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// src/tools/meta-campaigns.ts
|
|
171
|
+
import { z as z2 } from "zod";
|
|
172
|
+
|
|
173
|
+
// src/meta/client.ts
|
|
174
|
+
var META_API_VERSION = "v21.0";
|
|
175
|
+
var META_BASE_URL = `https://graph.facebook.com/${META_API_VERSION}`;
|
|
176
|
+
var THROTTLE_WARN_THRESHOLD = 75;
|
|
177
|
+
function getMetaAccessToken() {
|
|
178
|
+
const token = process.env.META_ACCESS_TOKEN;
|
|
179
|
+
if (!token) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
"Missing META_ACCESS_TOKEN environment variable. Provide a Meta Marketing API access token to use Meta tools."
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
return token;
|
|
185
|
+
}
|
|
186
|
+
function isMetaApiError(body) {
|
|
187
|
+
return typeof body === "object" && body !== null && "error" in body && typeof body.error?.message === "string";
|
|
188
|
+
}
|
|
189
|
+
function formatMetaError(err) {
|
|
190
|
+
const code = err.code;
|
|
191
|
+
const sub = err.error_subcode;
|
|
192
|
+
if (code === 190) {
|
|
193
|
+
return `Authentication error: Access token is invalid or expired. Generate a new token in Meta Business Suite. (code ${code})`;
|
|
194
|
+
}
|
|
195
|
+
if (code === 4 || code === 17 || code >= 8e4 && code <= 80099) {
|
|
196
|
+
return `Rate limit hit: ${err.message}. Wait a few minutes before retrying. (code ${code}${sub ? `/${sub}` : ""})`;
|
|
197
|
+
}
|
|
198
|
+
if (code === 100) {
|
|
199
|
+
return `Invalid parameter: ${err.message} (code ${code}${sub ? `/${sub}` : ""})`;
|
|
200
|
+
}
|
|
201
|
+
return `Meta API error: ${err.message} (code ${code}${sub ? `/${sub}` : ""})`;
|
|
202
|
+
}
|
|
203
|
+
function parseThrottleHeader(headers) {
|
|
204
|
+
const raw = headers.get("x-fb-ads-insights-throttle");
|
|
205
|
+
if (!raw) return void 0;
|
|
206
|
+
try {
|
|
207
|
+
const info = JSON.parse(raw);
|
|
208
|
+
const maxUtil = Math.max(info.app_id_util_pct, info.acc_id_util_pct);
|
|
209
|
+
const warning = maxUtil > THROTTLE_WARN_THRESHOLD ? `\u26A0 Meta API utilization at ${maxUtil}% \u2014 approaching rate limit. Slow down requests.` : void 0;
|
|
210
|
+
return { throttle: info, warning };
|
|
211
|
+
} catch {
|
|
212
|
+
return void 0;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
async function metaApiGet(options) {
|
|
216
|
+
const token = getMetaAccessToken();
|
|
217
|
+
const url = new URL(`${META_BASE_URL}${options.path}`);
|
|
218
|
+
if (options.params) {
|
|
219
|
+
for (const [key, value] of Object.entries(options.params)) {
|
|
220
|
+
url.searchParams.set(key, value);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const response = await fetch(url.toString(), {
|
|
224
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
225
|
+
});
|
|
226
|
+
const body = await response.json();
|
|
227
|
+
if (!response.ok || isMetaApiError(body)) {
|
|
228
|
+
if (isMetaApiError(body)) {
|
|
229
|
+
throw new Error(formatMetaError(body.error));
|
|
230
|
+
}
|
|
231
|
+
throw new Error(`Meta API returned ${response.status}: ${JSON.stringify(body)}`);
|
|
232
|
+
}
|
|
233
|
+
const throttleInfo = parseThrottleHeader(response.headers);
|
|
234
|
+
return {
|
|
235
|
+
data: body,
|
|
236
|
+
throttle: throttleInfo?.throttle,
|
|
237
|
+
warning: throttleInfo?.warning
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
function activeFilter() {
|
|
241
|
+
return JSON.stringify([
|
|
242
|
+
{
|
|
243
|
+
field: "effective_status",
|
|
244
|
+
operator: "IN",
|
|
245
|
+
value: ["ACTIVE"]
|
|
246
|
+
}
|
|
247
|
+
]);
|
|
248
|
+
}
|
|
249
|
+
function getActionValue(actions, actionType) {
|
|
250
|
+
if (!actions) return 0;
|
|
251
|
+
const action = actions.find((a) => a.action_type === actionType);
|
|
252
|
+
return action ? parseFloat(action.value) : 0;
|
|
253
|
+
}
|
|
254
|
+
function getCostPerAction(costPerAction, actionType) {
|
|
255
|
+
if (!costPerAction) return null;
|
|
256
|
+
const action = costPerAction.find((a) => a.action_type === actionType);
|
|
257
|
+
return action ? parseFloat(action.value) : null;
|
|
258
|
+
}
|
|
259
|
+
var DEFAULT_DATE_PRESET = "last_7d";
|
|
260
|
+
var CAMPAIGN_DEFAULT_FIELDS = "id,name,status,effective_status,objective,bid_strategy,daily_budget,lifetime_budget,buying_type,special_ad_categories";
|
|
261
|
+
var ADSET_DEFAULT_FIELDS = "id,name,status,effective_status,campaign_id,optimization_goal,billing_event,bid_strategy,bid_amount,daily_budget,lifetime_budget,targeting,promoted_object";
|
|
262
|
+
var AD_DEFAULT_FIELDS = "id,name,status,effective_status,adset_id,campaign_id,creative{id,title,body,image_url,video_id,call_to_action_type}";
|
|
263
|
+
var INSIGHT_DEFAULT_FIELDS = "campaign_id,campaign_name,spend,impressions,clicks,ctr,cpm,cpc,actions,cost_per_action_type";
|
|
264
|
+
|
|
265
|
+
// src/tools/meta-campaigns.ts
|
|
266
|
+
function registerGetMetaCampaigns(server2) {
|
|
267
|
+
server2.tool(
|
|
268
|
+
"get_meta_campaigns",
|
|
269
|
+
"List campaigns from a Meta ad account. Defaults to active campaigns with lean field set. Returns first page only \u2014 use 'after' cursor for next page.",
|
|
270
|
+
{
|
|
271
|
+
ad_account_id: z2.string().describe("Meta ad account ID (e.g. act_123456789)"),
|
|
272
|
+
fields: z2.string().optional().describe(
|
|
273
|
+
`Comma-separated fields. Default: ${CAMPAIGN_DEFAULT_FIELDS}`
|
|
274
|
+
),
|
|
275
|
+
effective_status: z2.array(z2.string()).optional().describe(
|
|
276
|
+
'Filter by status. Default: ["ACTIVE"]. Use ["ACTIVE","PAUSED"] for all non-deleted.'
|
|
277
|
+
),
|
|
278
|
+
limit: z2.number().min(1).max(100).optional().describe("Results per page (default 50, max 100)"),
|
|
279
|
+
after: z2.string().optional().describe("Pagination cursor from previous response")
|
|
280
|
+
},
|
|
281
|
+
async ({ ad_account_id, fields, effective_status, limit, after }) => {
|
|
282
|
+
try {
|
|
283
|
+
const params = {
|
|
284
|
+
fields: fields ?? CAMPAIGN_DEFAULT_FIELDS,
|
|
285
|
+
limit: String(limit ?? 50)
|
|
286
|
+
};
|
|
287
|
+
if (effective_status) {
|
|
288
|
+
params.filtering = JSON.stringify([
|
|
289
|
+
{
|
|
290
|
+
field: "effective_status",
|
|
291
|
+
operator: "IN",
|
|
292
|
+
value: effective_status
|
|
293
|
+
}
|
|
294
|
+
]);
|
|
295
|
+
} else {
|
|
296
|
+
params.filtering = activeFilter();
|
|
297
|
+
}
|
|
298
|
+
if (after) {
|
|
299
|
+
params.after = after;
|
|
300
|
+
}
|
|
301
|
+
const result = await metaApiGet({
|
|
302
|
+
path: `/${ad_account_id}/campaigns`,
|
|
303
|
+
params
|
|
304
|
+
});
|
|
305
|
+
const campaigns = result.data.data;
|
|
306
|
+
const nextCursor = result.data.paging?.cursors?.after;
|
|
307
|
+
let text = `Found ${campaigns.length} campaigns:
|
|
308
|
+
|
|
309
|
+
`;
|
|
310
|
+
for (const c of campaigns) {
|
|
311
|
+
text += `- **${c.name}** (${c.id})
|
|
312
|
+
Status: ${c.effective_status} | Objective: ${c.objective}` + (c.bid_strategy ? ` | Bid: ${c.bid_strategy}` : "") + (c.daily_budget ? ` | Daily: $${(parseInt(c.daily_budget) / 100).toFixed(2)}` : "") + (c.lifetime_budget ? ` | Lifetime: $${(parseInt(c.lifetime_budget) / 100).toFixed(2)}` : "") + "\n";
|
|
313
|
+
}
|
|
314
|
+
if (nextCursor) {
|
|
315
|
+
text += `
|
|
316
|
+
_More results available. Pass \`after: "${nextCursor}"\` for next page._`;
|
|
317
|
+
}
|
|
318
|
+
if (result.warning) {
|
|
319
|
+
text = `${result.warning}
|
|
320
|
+
|
|
321
|
+
${text}`;
|
|
322
|
+
}
|
|
323
|
+
return { content: [{ type: "text", text }] };
|
|
324
|
+
} catch (err) {
|
|
325
|
+
return {
|
|
326
|
+
content: [
|
|
327
|
+
{
|
|
328
|
+
type: "text",
|
|
329
|
+
text: `Error: ${err instanceof Error ? err.message : String(err)}`
|
|
330
|
+
}
|
|
331
|
+
],
|
|
332
|
+
isError: true
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// src/tools/meta-adsets.ts
|
|
340
|
+
import { z as z3 } from "zod";
|
|
341
|
+
function registerGetMetaAdSets(server2) {
|
|
342
|
+
server2.tool(
|
|
343
|
+
"get_meta_adsets",
|
|
344
|
+
"List ad sets from a Meta ad account, optionally scoped to a campaign. Defaults to active ad sets with lean field set.",
|
|
345
|
+
{
|
|
346
|
+
ad_account_id: z3.string().describe("Meta ad account ID (e.g. act_123456789)"),
|
|
347
|
+
campaign_id: z3.string().optional().describe(
|
|
348
|
+
"Scope to a specific campaign ID. If provided, fetches ad sets under that campaign."
|
|
349
|
+
),
|
|
350
|
+
fields: z3.string().optional().describe(`Comma-separated fields. Default: ${ADSET_DEFAULT_FIELDS}`),
|
|
351
|
+
effective_status: z3.array(z3.string()).optional().describe('Filter by status. Default: ["ACTIVE"]'),
|
|
352
|
+
limit: z3.number().min(1).max(100).optional().describe("Results per page (default 50, max 100)"),
|
|
353
|
+
after: z3.string().optional().describe("Pagination cursor from previous response")
|
|
354
|
+
},
|
|
355
|
+
async ({
|
|
356
|
+
ad_account_id,
|
|
357
|
+
campaign_id,
|
|
358
|
+
fields,
|
|
359
|
+
effective_status,
|
|
360
|
+
limit,
|
|
361
|
+
after
|
|
362
|
+
}) => {
|
|
363
|
+
try {
|
|
364
|
+
const params = {
|
|
365
|
+
fields: fields ?? ADSET_DEFAULT_FIELDS,
|
|
366
|
+
limit: String(limit ?? 50)
|
|
367
|
+
};
|
|
368
|
+
if (effective_status) {
|
|
369
|
+
params.filtering = JSON.stringify([
|
|
370
|
+
{
|
|
371
|
+
field: "effective_status",
|
|
372
|
+
operator: "IN",
|
|
373
|
+
value: effective_status
|
|
374
|
+
}
|
|
375
|
+
]);
|
|
376
|
+
} else {
|
|
377
|
+
params.filtering = activeFilter();
|
|
378
|
+
}
|
|
379
|
+
if (after) {
|
|
380
|
+
params.after = after;
|
|
381
|
+
}
|
|
382
|
+
const parentPath = campaign_id ? `/${campaign_id}/adsets` : `/${ad_account_id}/adsets`;
|
|
383
|
+
const result = await metaApiGet({
|
|
384
|
+
path: parentPath,
|
|
385
|
+
params
|
|
386
|
+
});
|
|
387
|
+
const adsets = result.data.data;
|
|
388
|
+
const nextCursor = result.data.paging?.cursors?.after;
|
|
389
|
+
let text = `Found ${adsets.length} ad sets:
|
|
390
|
+
|
|
391
|
+
`;
|
|
392
|
+
for (const a of adsets) {
|
|
393
|
+
text += `- **${a.name}** (${a.id})
|
|
394
|
+
Campaign: ${a.campaign_id} | Status: ${a.effective_status}` + (a.optimization_goal ? ` | Goal: ${a.optimization_goal}` : "") + (a.bid_strategy ? ` | Bid: ${a.bid_strategy}` : "") + (a.daily_budget ? ` | Daily: $${(parseInt(a.daily_budget) / 100).toFixed(2)}` : "") + "\n";
|
|
395
|
+
}
|
|
396
|
+
if (nextCursor) {
|
|
397
|
+
text += `
|
|
398
|
+
_More results available. Pass \`after: "${nextCursor}"\` for next page._`;
|
|
399
|
+
}
|
|
400
|
+
if (result.warning) {
|
|
401
|
+
text = `${result.warning}
|
|
402
|
+
|
|
403
|
+
${text}`;
|
|
404
|
+
}
|
|
405
|
+
return { content: [{ type: "text", text }] };
|
|
406
|
+
} catch (err) {
|
|
407
|
+
return {
|
|
408
|
+
content: [
|
|
409
|
+
{
|
|
410
|
+
type: "text",
|
|
411
|
+
text: `Error: ${err instanceof Error ? err.message : String(err)}`
|
|
412
|
+
}
|
|
413
|
+
],
|
|
414
|
+
isError: true
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// src/tools/meta-ads.ts
|
|
422
|
+
import { z as z4 } from "zod";
|
|
423
|
+
function registerGetMetaAds(server2) {
|
|
424
|
+
server2.tool(
|
|
425
|
+
"get_meta_ads",
|
|
426
|
+
"List ads from a Meta ad account, optionally scoped to an ad set. Defaults to active ads with lean field set.",
|
|
427
|
+
{
|
|
428
|
+
ad_account_id: z4.string().describe("Meta ad account ID (e.g. act_123456789)"),
|
|
429
|
+
adset_id: z4.string().optional().describe("Scope to a specific ad set ID."),
|
|
430
|
+
fields: z4.string().optional().describe(`Comma-separated fields. Default: ${AD_DEFAULT_FIELDS}`),
|
|
431
|
+
effective_status: z4.array(z4.string()).optional().describe('Filter by status. Default: ["ACTIVE"]'),
|
|
432
|
+
limit: z4.number().min(1).max(100).optional().describe("Results per page (default 50, max 100)"),
|
|
433
|
+
after: z4.string().optional().describe("Pagination cursor from previous response")
|
|
434
|
+
},
|
|
435
|
+
async ({ ad_account_id, adset_id, fields, effective_status, limit, after }) => {
|
|
436
|
+
try {
|
|
437
|
+
const params = {
|
|
438
|
+
fields: fields ?? AD_DEFAULT_FIELDS,
|
|
439
|
+
limit: String(limit ?? 50)
|
|
440
|
+
};
|
|
441
|
+
if (effective_status) {
|
|
442
|
+
params.filtering = JSON.stringify([
|
|
443
|
+
{
|
|
444
|
+
field: "effective_status",
|
|
445
|
+
operator: "IN",
|
|
446
|
+
value: effective_status
|
|
447
|
+
}
|
|
448
|
+
]);
|
|
449
|
+
} else {
|
|
450
|
+
params.filtering = activeFilter();
|
|
451
|
+
}
|
|
452
|
+
if (after) {
|
|
453
|
+
params.after = after;
|
|
454
|
+
}
|
|
455
|
+
const parentPath = adset_id ? `/${adset_id}/ads` : `/${ad_account_id}/ads`;
|
|
456
|
+
const result = await metaApiGet({
|
|
457
|
+
path: parentPath,
|
|
458
|
+
params
|
|
459
|
+
});
|
|
460
|
+
const ads = result.data.data;
|
|
461
|
+
const nextCursor = result.data.paging?.cursors?.after;
|
|
462
|
+
let text = `Found ${ads.length} ads:
|
|
463
|
+
|
|
464
|
+
`;
|
|
465
|
+
for (const ad of ads) {
|
|
466
|
+
text += `- **${ad.name}** (${ad.id})
|
|
467
|
+
Ad Set: ${ad.adset_id} | Status: ${ad.effective_status}` + (ad.creative?.call_to_action_type ? ` | CTA: ${ad.creative.call_to_action_type}` : "") + (ad.creative?.video_id ? " | Format: Video" : "") + (ad.creative?.image_url ? " | Format: Image" : "") + "\n";
|
|
468
|
+
}
|
|
469
|
+
if (nextCursor) {
|
|
470
|
+
text += `
|
|
471
|
+
_More results available. Pass \`after: "${nextCursor}"\` for next page._`;
|
|
472
|
+
}
|
|
473
|
+
if (result.warning) {
|
|
474
|
+
text = `${result.warning}
|
|
475
|
+
|
|
476
|
+
${text}`;
|
|
477
|
+
}
|
|
478
|
+
return { content: [{ type: "text", text }] };
|
|
479
|
+
} catch (err) {
|
|
480
|
+
return {
|
|
481
|
+
content: [
|
|
482
|
+
{
|
|
483
|
+
type: "text",
|
|
484
|
+
text: `Error: ${err instanceof Error ? err.message : String(err)}`
|
|
485
|
+
}
|
|
486
|
+
],
|
|
487
|
+
isError: true
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// src/tools/meta-insights.ts
|
|
495
|
+
import { z as z5 } from "zod";
|
|
496
|
+
function registerGetMetaInsights(server2) {
|
|
497
|
+
server2.tool(
|
|
498
|
+
"get_meta_insights",
|
|
499
|
+
"Pull performance insights from a Meta ad account with configurable level, breakdowns, and date range. Default: campaign-level, last 7 days, active only. Conversion event is configurable (default: mobile_app_install).",
|
|
500
|
+
{
|
|
501
|
+
ad_account_id: z5.string().describe("Meta ad account ID (e.g. act_123456789)"),
|
|
502
|
+
level: z5.enum(["account", "campaign", "adset", "ad"]).optional().describe("Aggregation level (default: campaign)"),
|
|
503
|
+
fields: z5.string().optional().describe(
|
|
504
|
+
`Comma-separated fields. Default: ${INSIGHT_DEFAULT_FIELDS}`
|
|
505
|
+
),
|
|
506
|
+
date_preset: z5.string().optional().describe(`Date preset (default: ${DEFAULT_DATE_PRESET})`),
|
|
507
|
+
time_range: z5.object({
|
|
508
|
+
since: z5.string().describe("Start date YYYY-MM-DD"),
|
|
509
|
+
until: z5.string().describe("End date YYYY-MM-DD")
|
|
510
|
+
}).optional().describe("Custom date range. Overrides date_preset if provided."),
|
|
511
|
+
time_increment: z5.string().optional().describe(
|
|
512
|
+
'Time granularity: "1" for daily, "7" for weekly, "monthly", or "all_days" (default: aggregated)'
|
|
513
|
+
),
|
|
514
|
+
breakdowns: z5.string().optional().describe(
|
|
515
|
+
"Comma-separated breakdowns (e.g. age,gender or publisher_platform,platform_position)"
|
|
516
|
+
),
|
|
517
|
+
filtering: z5.string().optional().describe(
|
|
518
|
+
'JSON filtering array. Default: active only. Pass "[]" to include all statuses.'
|
|
519
|
+
),
|
|
520
|
+
conversion_event: z5.string().optional().describe(
|
|
521
|
+
"Action type for CPA calculation (default: mobile_app_install)"
|
|
522
|
+
),
|
|
523
|
+
sort: z5.string().optional().describe(
|
|
524
|
+
'Sort field (e.g. "spend_descending", "impressions_descending")'
|
|
525
|
+
),
|
|
526
|
+
limit: z5.number().min(1).max(500).optional().describe("Results per page (default 50, max 500)"),
|
|
527
|
+
after: z5.string().optional().describe("Pagination cursor from previous response")
|
|
528
|
+
},
|
|
529
|
+
async ({
|
|
530
|
+
ad_account_id,
|
|
531
|
+
level,
|
|
532
|
+
fields,
|
|
533
|
+
date_preset,
|
|
534
|
+
time_range,
|
|
535
|
+
time_increment,
|
|
536
|
+
breakdowns,
|
|
537
|
+
filtering,
|
|
538
|
+
conversion_event,
|
|
539
|
+
sort,
|
|
540
|
+
limit,
|
|
541
|
+
after
|
|
542
|
+
}) => {
|
|
543
|
+
try {
|
|
544
|
+
const convEvent = conversion_event ?? "mobile_app_install";
|
|
545
|
+
const params = {
|
|
546
|
+
fields: fields ?? INSIGHT_DEFAULT_FIELDS,
|
|
547
|
+
level: level ?? "campaign",
|
|
548
|
+
limit: String(limit ?? 50)
|
|
549
|
+
};
|
|
550
|
+
if (time_range) {
|
|
551
|
+
params.time_range = JSON.stringify(time_range);
|
|
552
|
+
} else {
|
|
553
|
+
params.date_preset = date_preset ?? DEFAULT_DATE_PRESET;
|
|
554
|
+
}
|
|
555
|
+
if (time_increment) {
|
|
556
|
+
params.time_increment = time_increment;
|
|
557
|
+
}
|
|
558
|
+
if (breakdowns) {
|
|
559
|
+
params.breakdowns = breakdowns;
|
|
560
|
+
}
|
|
561
|
+
if (filtering !== void 0) {
|
|
562
|
+
params.filtering = filtering === "[]" ? "[]" : filtering;
|
|
563
|
+
} else {
|
|
564
|
+
params.filtering = activeFilter();
|
|
565
|
+
}
|
|
566
|
+
if (sort) {
|
|
567
|
+
params.sort = sort;
|
|
568
|
+
}
|
|
569
|
+
if (after) {
|
|
570
|
+
params.after = after;
|
|
571
|
+
}
|
|
572
|
+
const result = await metaApiGet({
|
|
573
|
+
path: `/${ad_account_id}/insights`,
|
|
574
|
+
params
|
|
575
|
+
});
|
|
576
|
+
const rows = result.data.data;
|
|
577
|
+
const nextCursor = result.data.paging?.cursors?.after;
|
|
578
|
+
if (!rows || rows.length === 0) {
|
|
579
|
+
return {
|
|
580
|
+
content: [
|
|
581
|
+
{
|
|
582
|
+
type: "text",
|
|
583
|
+
text: "No insight data returned for the given parameters. Try a broader date range or check that active campaigns exist."
|
|
584
|
+
}
|
|
585
|
+
]
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
let text = `**${rows.length} rows** | Level: ${level ?? "campaign"} | Event: ${convEvent}
|
|
589
|
+
|
|
590
|
+
`;
|
|
591
|
+
for (const row of rows) {
|
|
592
|
+
const spend = parseFloat(row.spend || "0");
|
|
593
|
+
const impressions = parseInt(row.impressions || "0");
|
|
594
|
+
const conversions = getActionValue(row.actions, convEvent);
|
|
595
|
+
const cpa = getCostPerAction(row.cost_per_action_type, convEvent);
|
|
596
|
+
const label = row.ad_name ?? row.adset_name ?? row.campaign_name ?? row.campaign_id ?? "Account";
|
|
597
|
+
const breakdownParts = [];
|
|
598
|
+
if (row.age) breakdownParts.push(`Age: ${row.age}`);
|
|
599
|
+
if (row.gender) breakdownParts.push(`Gender: ${row.gender}`);
|
|
600
|
+
if (row.publisher_platform)
|
|
601
|
+
breakdownParts.push(`Platform: ${row.publisher_platform}`);
|
|
602
|
+
if (row.platform_position)
|
|
603
|
+
breakdownParts.push(`Position: ${row.platform_position}`);
|
|
604
|
+
if (row.country) breakdownParts.push(`Country: ${row.country}`);
|
|
605
|
+
const breakdownStr = breakdownParts.length > 0 ? ` (${breakdownParts.join(", ")})` : "";
|
|
606
|
+
const dateStr = time_increment && row.date_start !== row.date_stop ? ` [${row.date_start} \u2192 ${row.date_stop}]` : time_increment ? ` [${row.date_start}]` : "";
|
|
607
|
+
text += `**${label}**${breakdownStr}${dateStr}
|
|
608
|
+
Spend: $${spend.toFixed(2)} | Impressions: ${impressions.toLocaleString()}` + (row.clicks ? ` | Clicks: ${row.clicks}` : "") + (row.ctr ? ` | CTR: ${row.ctr}%` : "") + (row.cpm ? ` | CPM: $${row.cpm}` : "") + (row.frequency ? ` | Freq: ${row.frequency}` : "") + (conversions > 0 ? ` | ${convEvent}: ${conversions}` : "") + (cpa !== null ? ` | CPA: $${cpa.toFixed(2)}` : "") + "\n\n";
|
|
609
|
+
}
|
|
610
|
+
if (nextCursor) {
|
|
611
|
+
text += `_More results available. Pass \`after: "${nextCursor}"\` for next page._
|
|
612
|
+
`;
|
|
613
|
+
}
|
|
614
|
+
if (result.warning) {
|
|
615
|
+
text = `${result.warning}
|
|
616
|
+
|
|
617
|
+
${text}`;
|
|
618
|
+
}
|
|
619
|
+
return { content: [{ type: "text", text }] };
|
|
620
|
+
} catch (err) {
|
|
621
|
+
return {
|
|
622
|
+
content: [
|
|
623
|
+
{
|
|
624
|
+
type: "text",
|
|
625
|
+
text: `Error: ${err instanceof Error ? err.message : String(err)}`
|
|
626
|
+
}
|
|
627
|
+
],
|
|
628
|
+
isError: true
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// src/tools/meta-ad-fatigue.ts
|
|
636
|
+
import { z as z6 } from "zod";
|
|
637
|
+
function registerGetMetaAdFatigue(server2) {
|
|
638
|
+
server2.tool(
|
|
639
|
+
"get_meta_ad_fatigue",
|
|
640
|
+
"Detect creative fatigue in active ads. Analyzes frequency, CTR decline, and CPA trends over the last 7 days at daily granularity. Grounded in knowledge base insights: wk-tw-001 (diagnostic patterns), ds-pt-003 (frequency thresholds), vs-nt-002 (degraded creative rules).",
|
|
641
|
+
{
|
|
642
|
+
ad_account_id: z6.string().describe("Meta ad account ID (e.g. act_123456789)"),
|
|
643
|
+
campaign_id: z6.string().optional().describe("Scope to a specific campaign"),
|
|
644
|
+
conversion_event: z6.string().optional().describe("Action type for CPA (default: mobile_app_install)"),
|
|
645
|
+
frequency_warning: z6.number().optional().describe("Frequency threshold for warning (default: 3)"),
|
|
646
|
+
frequency_critical: z6.number().optional().describe("Frequency threshold for critical (default: 5)"),
|
|
647
|
+
ctr_decline_threshold: z6.number().optional().describe(
|
|
648
|
+
"CTR decline % from peak to flag fatigue (default: 30)"
|
|
649
|
+
)
|
|
650
|
+
},
|
|
651
|
+
async ({
|
|
652
|
+
ad_account_id,
|
|
653
|
+
campaign_id,
|
|
654
|
+
conversion_event,
|
|
655
|
+
frequency_warning,
|
|
656
|
+
frequency_critical,
|
|
657
|
+
ctr_decline_threshold
|
|
658
|
+
}) => {
|
|
659
|
+
try {
|
|
660
|
+
const convEvent = conversion_event ?? "mobile_app_install";
|
|
661
|
+
const freqWarn = frequency_warning ?? 3;
|
|
662
|
+
const freqCrit = frequency_critical ?? 5;
|
|
663
|
+
const ctrThreshold = ctr_decline_threshold ?? 30;
|
|
664
|
+
const parentPath = campaign_id ? `/${campaign_id}/insights` : `/${ad_account_id}/insights`;
|
|
665
|
+
const result = await metaApiGet({
|
|
666
|
+
path: parentPath,
|
|
667
|
+
params: {
|
|
668
|
+
level: "ad",
|
|
669
|
+
time_increment: "1",
|
|
670
|
+
fields: "ad_id,ad_name,spend,impressions,clicks,ctr,cpm,frequency,actions,cost_per_action_type",
|
|
671
|
+
date_preset: "last_7d",
|
|
672
|
+
filtering: activeFilter(),
|
|
673
|
+
limit: "500"
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
const rows = result.data.data;
|
|
677
|
+
if (!rows || rows.length === 0) {
|
|
678
|
+
return {
|
|
679
|
+
content: [
|
|
680
|
+
{
|
|
681
|
+
type: "text",
|
|
682
|
+
text: "No active ad data found for the last 7 days."
|
|
683
|
+
}
|
|
684
|
+
]
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
const adMap = /* @__PURE__ */ new Map();
|
|
688
|
+
for (const row of rows) {
|
|
689
|
+
const adId = row.ad_id;
|
|
690
|
+
if (!adMap.has(adId)) {
|
|
691
|
+
adMap.set(adId, {
|
|
692
|
+
ad_id: adId,
|
|
693
|
+
ad_name: row.ad_name ?? adId,
|
|
694
|
+
days: []
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
adMap.get(adId).days.push({
|
|
698
|
+
date: row.date_start,
|
|
699
|
+
spend: parseFloat(row.spend || "0"),
|
|
700
|
+
impressions: parseInt(row.impressions || "0"),
|
|
701
|
+
clicks: parseInt(row.clicks || "0"),
|
|
702
|
+
ctr: parseFloat(row.ctr || "0"),
|
|
703
|
+
cpm: parseFloat(row.cpm || "0"),
|
|
704
|
+
frequency: parseFloat(row.frequency || "0"),
|
|
705
|
+
conversions: getActionValue(row.actions, convEvent),
|
|
706
|
+
cpa: getCostPerAction(row.cost_per_action_type, convEvent)
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
const results = [];
|
|
710
|
+
let totalSpend = 0;
|
|
711
|
+
for (const ad of adMap.values()) {
|
|
712
|
+
ad.days.sort(
|
|
713
|
+
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
|
|
714
|
+
);
|
|
715
|
+
const adSpend = ad.days.reduce((s, d) => s + d.spend, 0);
|
|
716
|
+
totalSpend += adSpend;
|
|
717
|
+
const avgFreq = ad.days.reduce((s, d) => s + d.frequency, 0) / ad.days.length;
|
|
718
|
+
const peakCtr = Math.max(...ad.days.map((d) => d.ctr));
|
|
719
|
+
const last3 = ad.days.slice(-3);
|
|
720
|
+
const recentCtr = last3.reduce((s, d) => s + d.ctr, 0) / last3.length;
|
|
721
|
+
const ctrDecline = peakCtr > 0 ? (peakCtr - recentCtr) / peakCtr * 100 : 0;
|
|
722
|
+
const mid = Math.floor(ad.days.length / 2);
|
|
723
|
+
const earlyDays = ad.days.slice(0, Math.max(mid, 1));
|
|
724
|
+
const lateDays = ad.days.slice(mid);
|
|
725
|
+
const earlyConv = earlyDays.reduce((s, d) => s + d.conversions, 0);
|
|
726
|
+
const earlySpend = earlyDays.reduce((s, d) => s + d.spend, 0);
|
|
727
|
+
const earlyCpa = earlyConv > 0 ? earlySpend / earlyConv : null;
|
|
728
|
+
const lateConv = lateDays.reduce((s, d) => s + d.conversions, 0);
|
|
729
|
+
const lateSpend = lateDays.reduce((s, d) => s + d.spend, 0);
|
|
730
|
+
const recentCpa = lateConv > 0 ? lateSpend / lateConv : null;
|
|
731
|
+
const cpaChange = earlyCpa !== null && recentCpa !== null && earlyCpa > 0 ? (recentCpa - earlyCpa) / earlyCpa * 100 : null;
|
|
732
|
+
let status = "HEALTHY";
|
|
733
|
+
let diagnosis = "Metrics stable \u2014 no fatigue detected.";
|
|
734
|
+
const highFreq = avgFreq >= freqCrit;
|
|
735
|
+
const medFreq = avgFreq >= freqWarn;
|
|
736
|
+
const ctrDeclining = ctrDecline >= ctrThreshold;
|
|
737
|
+
const cpaRising = cpaChange !== null && cpaChange > 20;
|
|
738
|
+
if (highFreq && cpaRising) {
|
|
739
|
+
status = "FATIGUED";
|
|
740
|
+
diagnosis = `Audience saturation: frequency ${avgFreq.toFixed(1)} + CPA rising ${cpaChange.toFixed(0)}% [wk-tw-001 #1, ds-pt-003]`;
|
|
741
|
+
} else if (ctrDeclining && cpaRising) {
|
|
742
|
+
status = "FATIGUED";
|
|
743
|
+
diagnosis = `Creative fatigue: CTR declined ${ctrDecline.toFixed(0)}% from peak + CPA rising ${cpaChange.toFixed(0)}% [wk-tw-001 #4]`;
|
|
744
|
+
} else if (highFreq) {
|
|
745
|
+
status = "WARNING";
|
|
746
|
+
diagnosis = `High frequency (${avgFreq.toFixed(1)}) \u2014 approaching saturation [ds-pt-003]. CPA ${cpaRising ? "rising" : "stable"}.`;
|
|
747
|
+
} else if (ctrDeclining) {
|
|
748
|
+
status = "WARNING";
|
|
749
|
+
diagnosis = `CTR declining ${ctrDecline.toFixed(0)}% from peak \u2014 early fatigue signal [wk-tw-001 #4].`;
|
|
750
|
+
} else if (medFreq && cpaRising) {
|
|
751
|
+
status = "WARNING";
|
|
752
|
+
diagnosis = `Frequency ${avgFreq.toFixed(1)} + CPA trending up ${cpaChange.toFixed(0)}% \u2014 monitor closely [wk-tw-001 #1].`;
|
|
753
|
+
}
|
|
754
|
+
results.push({
|
|
755
|
+
ad_id: ad.ad_id,
|
|
756
|
+
ad_name: ad.ad_name,
|
|
757
|
+
total_spend: adSpend,
|
|
758
|
+
avg_frequency: avgFreq,
|
|
759
|
+
peak_ctr: peakCtr,
|
|
760
|
+
recent_ctr: recentCtr,
|
|
761
|
+
ctr_decline_pct: ctrDecline,
|
|
762
|
+
early_cpa: earlyCpa,
|
|
763
|
+
recent_cpa: recentCpa,
|
|
764
|
+
cpa_change_pct: cpaChange,
|
|
765
|
+
status,
|
|
766
|
+
diagnosis
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
const statusOrder = { FATIGUED: 0, WARNING: 1, HEALTHY: 2 };
|
|
770
|
+
results.sort(
|
|
771
|
+
(a, b) => statusOrder[a.status] - statusOrder[b.status] || b.total_spend - a.total_spend
|
|
772
|
+
);
|
|
773
|
+
const fatigued = results.filter((r) => r.status === "FATIGUED");
|
|
774
|
+
const warning = results.filter((r) => r.status === "WARNING");
|
|
775
|
+
const healthy = results.filter((r) => r.status === "HEALTHY");
|
|
776
|
+
const fatiguedSpend = fatigued.reduce((s, r) => s + r.total_spend, 0);
|
|
777
|
+
const spendConcentration = totalSpend > 0 ? fatiguedSpend / totalSpend * 100 : 0;
|
|
778
|
+
let text = `# Ad Fatigue Report
|
|
779
|
+
|
|
780
|
+
`;
|
|
781
|
+
text += `**${results.length} ads analyzed** | ${fatigued.length} fatigued | ${warning.length} warning | ${healthy.length} healthy
|
|
782
|
+
`;
|
|
783
|
+
text += `**Total spend:** $${totalSpend.toFixed(2)} | **Fatigued ad spend:** $${fatiguedSpend.toFixed(2)} (${spendConcentration.toFixed(0)}%)
|
|
784
|
+
|
|
785
|
+
`;
|
|
786
|
+
if (spendConcentration > 50) {
|
|
787
|
+
text += `\u26A0 **Over 50% of spend is going to fatigued ads.** Urgent creative rotation needed.
|
|
788
|
+
|
|
789
|
+
`;
|
|
790
|
+
}
|
|
791
|
+
text += `| Status | Ad | Spend | Freq | CTR (peak\u2192recent) | CPA Trend | Diagnosis |
|
|
792
|
+
`;
|
|
793
|
+
text += `|--------|-----|-------|------|-------------------|-----------|------------|
|
|
794
|
+
`;
|
|
795
|
+
for (const r of results) {
|
|
796
|
+
const icon = r.status === "FATIGUED" ? "\u{1F534}" : r.status === "WARNING" ? "\u{1F7E1}" : "\u{1F7E2}";
|
|
797
|
+
text += `| ${icon} ${r.status} | ${r.ad_name.slice(0, 30)} | $${r.total_spend.toFixed(0)} | ${r.avg_frequency.toFixed(1)} | ${r.peak_ctr.toFixed(2)}%\u2192${r.recent_ctr.toFixed(2)}% (${r.ctr_decline_pct > 0 ? "-" : ""}${r.ctr_decline_pct.toFixed(0)}%) | ${r.cpa_change_pct !== null ? `${r.cpa_change_pct > 0 ? "+" : ""}${r.cpa_change_pct.toFixed(0)}%` : "N/A"} | ${r.diagnosis} |
|
|
798
|
+
`;
|
|
799
|
+
}
|
|
800
|
+
text += `
|
|
801
|
+
## Recommendations
|
|
802
|
+
|
|
803
|
+
`;
|
|
804
|
+
if (fatigued.length > 0) {
|
|
805
|
+
text += `### Fatigued Ads (${fatigued.length})
|
|
806
|
+
- **Rotate with genuinely new creative concepts**, not variations. Meta's Andromeda requires at least 25% visual difference to treat it as a new ad [lp-pt-001, oh-li-001].
|
|
807
|
+
- **Change the hook** (first 3 seconds) to access new audience segments [oh-li-005].
|
|
808
|
+
- **Do NOT re-test degraded creatives** \u2014 exhaust all other angles first. Wait 3-6 months before revisiting [vs-nt-002].
|
|
809
|
+
- Meta will auto-shift spend away from degrading creatives [vs-nt-001] \u2014 don't panic-pause, but do prepare replacements.
|
|
810
|
+
|
|
811
|
+
`;
|
|
812
|
+
}
|
|
813
|
+
if (healthy.length > 0) {
|
|
814
|
+
text += `### Healthy Ads (${healthy.length})
|
|
815
|
+
- Let Meta manage allocation. Don't force spend to low-spend winners \u2014 Meta has determined scaling them would degrade performance [ds-pt-004].
|
|
816
|
+
|
|
817
|
+
`;
|
|
818
|
+
}
|
|
819
|
+
if (result.warning) {
|
|
820
|
+
text = `${result.warning}
|
|
821
|
+
|
|
822
|
+
${text}`;
|
|
823
|
+
}
|
|
824
|
+
return { content: [{ type: "text", text }] };
|
|
825
|
+
} catch (err) {
|
|
826
|
+
return {
|
|
827
|
+
content: [
|
|
828
|
+
{
|
|
829
|
+
type: "text",
|
|
830
|
+
text: `Error: ${err instanceof Error ? err.message : String(err)}`
|
|
831
|
+
}
|
|
832
|
+
],
|
|
833
|
+
isError: true
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// ../shared/dist/types.js
|
|
841
|
+
var VALID_PLATFORMS = [
|
|
842
|
+
"meta",
|
|
843
|
+
"google",
|
|
844
|
+
"tiktok",
|
|
845
|
+
"cross_platform"
|
|
846
|
+
];
|
|
847
|
+
var TOPICS = [
|
|
848
|
+
"scaling",
|
|
849
|
+
"creative_strategy",
|
|
850
|
+
"creative_testing",
|
|
851
|
+
"creative_iteration",
|
|
852
|
+
"creative_fatigue",
|
|
853
|
+
"campaign_architecture",
|
|
854
|
+
"campaign_structure",
|
|
855
|
+
"funnel_strategy",
|
|
856
|
+
"funnel_optimization",
|
|
857
|
+
"audience_targeting",
|
|
858
|
+
"bid_strategy",
|
|
859
|
+
"bidding",
|
|
860
|
+
"budgeting",
|
|
861
|
+
"value_rules",
|
|
862
|
+
"cpi_optimization",
|
|
863
|
+
"campaign_optimization",
|
|
864
|
+
"aso",
|
|
865
|
+
"measurement",
|
|
866
|
+
"attribution",
|
|
867
|
+
"signal_engineering",
|
|
868
|
+
"conversion_events",
|
|
869
|
+
"pricing",
|
|
870
|
+
"subscription",
|
|
871
|
+
"monetization",
|
|
872
|
+
"paywall_optimization",
|
|
873
|
+
"ltv_modeling",
|
|
874
|
+
"onboarding",
|
|
875
|
+
"advantage_plus",
|
|
876
|
+
"eac",
|
|
877
|
+
"web_to_app",
|
|
878
|
+
"ad_ranking",
|
|
879
|
+
"meta_ads",
|
|
880
|
+
"google_ads",
|
|
881
|
+
"retention",
|
|
882
|
+
"first_party_data",
|
|
883
|
+
"surveys",
|
|
884
|
+
"ai_tools",
|
|
885
|
+
"automation",
|
|
886
|
+
"testing_framework",
|
|
887
|
+
"competitive_analysis",
|
|
888
|
+
"ad_copy",
|
|
889
|
+
"conversion_rate",
|
|
890
|
+
"strategy"
|
|
891
|
+
];
|
|
892
|
+
var APPLIES_TO = [
|
|
893
|
+
"subscription_apps",
|
|
894
|
+
"mobile_gaming",
|
|
895
|
+
"ios",
|
|
896
|
+
"android",
|
|
897
|
+
"mobile",
|
|
898
|
+
"web2app",
|
|
899
|
+
"web_funnels",
|
|
900
|
+
"ecommerce",
|
|
901
|
+
"all"
|
|
902
|
+
];
|
|
903
|
+
|
|
904
|
+
// src/resources/vocabulary.ts
|
|
905
|
+
function registerVocabularyResource(server2) {
|
|
906
|
+
server2.resource(
|
|
907
|
+
"vocabulary",
|
|
908
|
+
"vocabulary://tags",
|
|
909
|
+
{
|
|
910
|
+
description: "Available topic tags, applies_to tags, and platforms for filtering insights. Includes counts from the database.",
|
|
911
|
+
mimeType: "application/json"
|
|
912
|
+
},
|
|
913
|
+
async () => {
|
|
914
|
+
let topicCounts = {};
|
|
915
|
+
let appliesToCounts = {};
|
|
916
|
+
try {
|
|
917
|
+
const apiKey2 = process.env.API_KEY;
|
|
918
|
+
if (apiKey2) {
|
|
919
|
+
const result = await callRemoteTool(apiKey2, "get_vocabulary_counts", {});
|
|
920
|
+
if (!result.isError && result.content.length > 0) {
|
|
921
|
+
const counts = JSON.parse(result.content[0].text);
|
|
922
|
+
topicCounts = counts.topics ?? {};
|
|
923
|
+
appliesToCounts = counts.applies_to ?? {};
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
} catch {
|
|
927
|
+
}
|
|
928
|
+
const payload = {
|
|
929
|
+
topics: TOPICS.map((t) => ({ tag: t, count: topicCounts[t] ?? 0 })),
|
|
930
|
+
applies_to: APPLIES_TO.map((a) => ({
|
|
931
|
+
tag: a,
|
|
932
|
+
count: appliesToCounts[a] ?? 0
|
|
933
|
+
})),
|
|
934
|
+
platforms: [...VALID_PLATFORMS]
|
|
935
|
+
};
|
|
936
|
+
return {
|
|
937
|
+
contents: [
|
|
938
|
+
{
|
|
939
|
+
uri: "vocabulary://tags",
|
|
940
|
+
mimeType: "application/json",
|
|
941
|
+
text: JSON.stringify(payload, null, 2)
|
|
942
|
+
}
|
|
943
|
+
]
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// src/resources/instructions.ts
|
|
950
|
+
var INSTRUCTIONS = `# Mobile Growth MCP \u2014 Knowledge Base + Meta Ad Tools
|
|
951
|
+
|
|
952
|
+
## What This Is
|
|
953
|
+
A curated knowledge base of mobile advertising insights + direct Meta Marketing API integration. Query expert knowledge, pull live campaign data, and run pre-built reports \u2014 all from your LLM.
|
|
954
|
+
|
|
955
|
+
## Knowledge Base Tools
|
|
956
|
+
|
|
957
|
+
### search_insights
|
|
958
|
+
Semantic + keyword hybrid search across the knowledge base.
|
|
959
|
+
- **query** (required): Natural language search query
|
|
960
|
+
- **topics** (optional): Filter by topic tags, e.g. ["creative_strategy", "scaling"]
|
|
961
|
+
- **applies_to** (optional): Filter by applicability, e.g. ["subscription_apps", "ios"]
|
|
962
|
+
- **limit** (optional): Max results, 1-30, default 10
|
|
963
|
+
|
|
964
|
+
### list_insights
|
|
965
|
+
Browse all insights with optional filtering. Returns titles and metadata.
|
|
966
|
+
- **topic** (optional): Filter by a single topic tag
|
|
967
|
+
- **applies_to** (optional): Filter by a single applies_to value
|
|
968
|
+
|
|
969
|
+
### get_insight
|
|
970
|
+
Fetch the full content of a specific insight by ID or slug.
|
|
971
|
+
- **id** (required): Numeric ID or string slug (e.g. "mb-li-001")
|
|
972
|
+
|
|
973
|
+
## Meta Marketing API Tools
|
|
974
|
+
|
|
975
|
+
**Requires META_ACCESS_TOKEN env var** \u2014 without it, these tools return a clear error. Knowledge base tools work with just API_KEY.
|
|
976
|
+
|
|
977
|
+
**Rate limit safety**: All tools default to last_7d, active-only, minimal fields. No auto-pagination. Throttle header monitored \u2014 warns at >75% utilization.
|
|
978
|
+
|
|
979
|
+
### get_meta_campaigns
|
|
980
|
+
List campaigns from a Meta ad account. Defaults to active campaigns.
|
|
981
|
+
- **ad_account_id** (required): e.g. "act_123456789"
|
|
982
|
+
- **fields, effective_status, limit, after** (optional)
|
|
983
|
+
|
|
984
|
+
### get_meta_adsets
|
|
985
|
+
List ad sets, optionally scoped to a campaign.
|
|
986
|
+
- **ad_account_id** (required)
|
|
987
|
+
- **campaign_id** (optional): Scope to specific campaign
|
|
988
|
+
- **fields, effective_status, limit, after** (optional)
|
|
989
|
+
|
|
990
|
+
### get_meta_ads
|
|
991
|
+
List ads, optionally scoped to an ad set.
|
|
992
|
+
- **ad_account_id** (required)
|
|
993
|
+
- **adset_id** (optional): Scope to specific ad set
|
|
994
|
+
- **fields, effective_status, limit, after** (optional)
|
|
995
|
+
|
|
996
|
+
### get_meta_insights
|
|
997
|
+
Pull performance insights with configurable level, breakdowns, date range.
|
|
998
|
+
- **ad_account_id** (required)
|
|
999
|
+
- **level** (optional): account, campaign, adset, ad (default: campaign)
|
|
1000
|
+
- **date_preset** (optional): default last_7d
|
|
1001
|
+
- **time_range** (optional): {since, until} for custom dates
|
|
1002
|
+
- **time_increment** (optional): "1" for daily, "7" for weekly
|
|
1003
|
+
- **breakdowns** (optional): e.g. "age,gender" or "publisher_platform,platform_position"
|
|
1004
|
+
- **conversion_event** (optional): default "mobile_app_install"
|
|
1005
|
+
- **fields, filtering, sort, limit, after** (optional)
|
|
1006
|
+
|
|
1007
|
+
### get_meta_ad_fatigue
|
|
1008
|
+
Built-in report: detect creative fatigue via frequency, CTR decline, CPA trends.
|
|
1009
|
+
- **ad_account_id** (required)
|
|
1010
|
+
- **campaign_id** (optional): Scope to specific campaign
|
|
1011
|
+
- **conversion_event** (optional): default "mobile_app_install"
|
|
1012
|
+
- **frequency_warning** (optional): default 3
|
|
1013
|
+
- **frequency_critical** (optional): default 5
|
|
1014
|
+
- **ctr_decline_threshold** (optional): default 30%
|
|
1015
|
+
|
|
1016
|
+
## Reports (MCP Prompts)
|
|
1017
|
+
|
|
1018
|
+
Pre-built analysis workflows. Select a prompt and provide your ad_account_id to run:
|
|
1019
|
+
|
|
1020
|
+
| Prompt | What it does | API calls |
|
|
1021
|
+
|--------|-------------|-----------|
|
|
1022
|
+
| ad-fatigue-report | Detect creative fatigue with daily granularity | 1 |
|
|
1023
|
+
| weekly-performance | Week-over-week health comparison with diagnosis | 2 |
|
|
1024
|
+
| creative-performance | Categorize ads by health status | 1 |
|
|
1025
|
+
| placement-efficiency | Identify placement waste and savings | 1 per campaign |
|
|
1026
|
+
| audience-composition | Age \xD7 gender heatmap with CPA analysis | 1-2 |
|
|
1027
|
+
| architecture-review | Campaign structure evaluation | 3 (no insights) |
|
|
1028
|
+
| audit-meta-account | Comprehensive account audit | 6+ |
|
|
1029
|
+
| campaign-comparison | Side-by-side campaign comparison | 3+ |
|
|
1030
|
+
| placement-audit | Detailed placement audit with examples | 1 per campaign |
|
|
1031
|
+
| attribution-analysis | Conversion quality validation | 2+ |
|
|
1032
|
+
|
|
1033
|
+
## Resources
|
|
1034
|
+
|
|
1035
|
+
### vocabulary://tags
|
|
1036
|
+
Lists all topic tags, applies_to tags, and platforms with counts.
|
|
1037
|
+
|
|
1038
|
+
## Tips
|
|
1039
|
+
- Start with \`list_insights\` to see what's in the knowledge base
|
|
1040
|
+
- Use \`search_insights\` to find specific advice grounded in expert knowledge
|
|
1041
|
+
- Meta tools default to safe parameters (last_7d, active-only) to avoid rate limits
|
|
1042
|
+
- Reports reference specific knowledge base insight IDs \u2014 use \`get_insight\` to read the full context
|
|
1043
|
+
- For custom date ranges, use time_range instead of date_preset
|
|
1044
|
+
`;
|
|
1045
|
+
function registerInstructionsResource(server2) {
|
|
1046
|
+
server2.resource(
|
|
1047
|
+
"instructions",
|
|
1048
|
+
"instructions://getting-started",
|
|
1049
|
+
{
|
|
1050
|
+
description: "Getting started guide explaining the knowledge base, Meta API tools, reports, and example queries. Read this first.",
|
|
1051
|
+
mimeType: "text/plain"
|
|
1052
|
+
},
|
|
1053
|
+
async () => ({
|
|
1054
|
+
contents: [
|
|
1055
|
+
{
|
|
1056
|
+
uri: "instructions://getting-started",
|
|
1057
|
+
mimeType: "text/plain",
|
|
1058
|
+
text: INSTRUCTIONS
|
|
1059
|
+
}
|
|
1060
|
+
]
|
|
1061
|
+
})
|
|
1062
|
+
);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// src/index.ts
|
|
1066
|
+
var apiKey = process.env.API_KEY;
|
|
1067
|
+
if (!apiKey) {
|
|
1068
|
+
console.error(
|
|
1069
|
+
'Error: API_KEY environment variable is required.\nGet your key from the server admin and add it to your MCP config:\n "env": { "API_KEY": "me_..." }'
|
|
1070
|
+
);
|
|
1071
|
+
process.exit(1);
|
|
1072
|
+
}
|
|
1073
|
+
var server = new McpServer({
|
|
1074
|
+
name: "mobile-growth-mcp",
|
|
1075
|
+
version: "2.0.0"
|
|
1076
|
+
});
|
|
1077
|
+
console.error("Connecting to knowledge base...");
|
|
1078
|
+
await registerRemoteTools(server, apiKey);
|
|
1079
|
+
registerGetMetaCampaigns(server);
|
|
1080
|
+
registerGetMetaAdSets(server);
|
|
1081
|
+
registerGetMetaAds(server);
|
|
1082
|
+
registerGetMetaInsights(server);
|
|
1083
|
+
registerGetMetaAdFatigue(server);
|
|
1084
|
+
registerVocabularyResource(server);
|
|
1085
|
+
registerInstructionsResource(server);
|
|
1086
|
+
await registerRemotePrompts(server, apiKey);
|
|
1087
|
+
var transport = new StdioServerTransport();
|
|
1088
|
+
await server.connect(transport);
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mobile-growth-mcp",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "MCP server for mobile growth & UA knowledge base — campaign optimization, creative strategy, and subscription app insights",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"mobile-growth-mcp": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsup",
|
|
15
|
+
"start": "node dist/index.js",
|
|
16
|
+
"prepublishOnly": "npm run build"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
20
|
+
"zod": "^3.24.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@mobile-growth/shared": "*",
|
|
24
|
+
"tsup": "^8.5.1",
|
|
25
|
+
"typescript": "^5.7.0"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"mcp",
|
|
29
|
+
"mobile",
|
|
30
|
+
"growth",
|
|
31
|
+
"user-acquisition",
|
|
32
|
+
"meta-ads",
|
|
33
|
+
"subscription-apps"
|
|
34
|
+
],
|
|
35
|
+
"license": "MIT"
|
|
36
|
+
}
|