@praise25/meta-mcp-server 0.1.8 → 0.1.10
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/LICENSE +21 -21
- package/README.md +292 -292
- package/dist/config.d.ts +24 -0
- package/dist/config.js +14 -0
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +1 -1
- package/dist/helpers/graph-client.d.ts +11 -1
- package/dist/helpers/graph-client.js +24 -16
- package/dist/index.js +0 -0
- package/dist/server.js +21 -1
- package/dist/tools/ads/get-insights.d.ts +1 -1
- package/dist/tools/ads/get-insights.js +12 -10
- package/dist/tools/ads/list-accounts.js +4 -4
- package/dist/tools/business/list-assets.js +10 -10
- package/dist/tools/business/list-businesses.js +4 -4
- package/dist/tools/business/list-system-users.js +4 -4
- package/dist/tools/instagram/get-audience-demographics.js +9 -9
- package/dist/tools/instagram/get-media-insights.js +14 -14
- package/dist/tools/instagram/list-accounts.js +2 -2
- package/dist/tools/meta/graph-read.js +7 -7
- package/dist/tools/overview/business-overview.js +10 -10
- package/dist/tools/overview/content-report.d.ts +57 -0
- package/dist/tools/overview/content-report.js +318 -0
- package/dist/tools/overview/latest-posts-summary.d.ts +1 -1
- package/dist/tools/overview/latest-posts-summary.js +10 -16
- package/dist/tools/pages/get-insights.js +2 -2
- package/dist/tools/pages/get-post-insights.js +4 -4
- package/dist/tools/pages/list-posts.d.ts +1 -1
- package/dist/tools/pages/list-posts.js +3 -1
- package/dist/tools/register.js +3 -2
- package/dist/tools/shared.d.ts +19 -4
- package/dist/tools/shared.js +56 -0
- package/dist/tools/token/health.js +8 -6
- package/dist/tools/token/inspect.js +11 -11
- package/dist/tools/whatsapp/get-analytics.js +2 -2
- package/package.json +77 -77
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { insightsCreds } from "../../config.js";
|
|
3
|
+
import { MetaError } from "../../errors.js";
|
|
4
|
+
import { jsonBlock, toolResult } from "../../helpers/format.js";
|
|
5
|
+
/**
|
|
6
|
+
* Window presets → number of days back from now. Kept friendly so the AI can
|
|
7
|
+
* pass the same vocabulary users type ("last 7 days", "this week").
|
|
8
|
+
*/
|
|
9
|
+
const WINDOW_DAYS = {
|
|
10
|
+
last_7d: 7,
|
|
11
|
+
last_14d: 14,
|
|
12
|
+
last_28d: 28,
|
|
13
|
+
last_30d: 30,
|
|
14
|
+
last_90d: 90,
|
|
15
|
+
};
|
|
16
|
+
export const inputSchema = z
|
|
17
|
+
.object({
|
|
18
|
+
date_preset: z
|
|
19
|
+
.enum(["last_7d", "last_14d", "last_28d", "last_30d", "last_90d"])
|
|
20
|
+
.default("last_7d")
|
|
21
|
+
.describe("Time window for the report. 'last_7d' = the last 7 days / this week (default). Overridden by since/until if those are supplied."),
|
|
22
|
+
since: z
|
|
23
|
+
.string()
|
|
24
|
+
.optional()
|
|
25
|
+
.describe("Explicit window start, ISO date e.g. '2026-05-01'. Overrides date_preset."),
|
|
26
|
+
until: z
|
|
27
|
+
.string()
|
|
28
|
+
.optional()
|
|
29
|
+
.describe("Explicit window end, ISO date e.g. '2026-05-31'. Defaults to now."),
|
|
30
|
+
include_facebook: z.boolean().default(true).describe("Include Facebook Page posts."),
|
|
31
|
+
include_instagram: z.boolean().default(true).describe("Include Instagram media."),
|
|
32
|
+
include_insights: z
|
|
33
|
+
.boolean()
|
|
34
|
+
.default(false)
|
|
35
|
+
.describe("Also fetch per-post reach/views/impressions for the top posts (capped by insights_top_n). Off by default because it costs one Graph call per post; engagement counts (likes/comments/shares/reactions) are ALWAYS included for free regardless."),
|
|
36
|
+
insights_top_n: z
|
|
37
|
+
.number()
|
|
38
|
+
.int()
|
|
39
|
+
.min(1)
|
|
40
|
+
.max(25)
|
|
41
|
+
.default(5)
|
|
42
|
+
.describe("When include_insights=true, how many top-engagement posts per platform to fetch insights for."),
|
|
43
|
+
max_pages: z.number().int().min(1).max(20).default(10).describe("Cap on Pages walked."),
|
|
44
|
+
max_posts_per_account: z
|
|
45
|
+
.number()
|
|
46
|
+
.int()
|
|
47
|
+
.min(1)
|
|
48
|
+
.max(200)
|
|
49
|
+
.default(100)
|
|
50
|
+
.describe("Cap on posts fetched per account before classifying."),
|
|
51
|
+
})
|
|
52
|
+
.strict();
|
|
53
|
+
export const definition = {
|
|
54
|
+
name: "meta_content_report",
|
|
55
|
+
title: "Content report: how many posts over a period, by type, with engagement & comparison",
|
|
56
|
+
description: `One-call content-performance report over a TIME WINDOW (default last 7 days / this week) across all Facebook Pages and Instagram accounts.
|
|
57
|
+
|
|
58
|
+
USE FOR questions like:
|
|
59
|
+
- "How many posts have gone up in the last week / 7 days / this month?"
|
|
60
|
+
- "Classify my posts by type (photo / video / reel / carousel)."
|
|
61
|
+
- "Compare Facebook vs Instagram content performance."
|
|
62
|
+
- "Which post type drives the most engagement?"
|
|
63
|
+
- "Give me a weekly content report / performance comparison."
|
|
64
|
+
|
|
65
|
+
NOT FOR: a single most-recent post → use meta_latest_posts_summary. A whole-business snapshot (ads + pages + pixels) → use meta_business_overview. Audience age/gender/country demographics → use meta_ig_get_audience_demographics (account-level, not per-post).
|
|
66
|
+
|
|
67
|
+
Returns, in one call:
|
|
68
|
+
- Total post count in the window, per platform (Facebook / Instagram)
|
|
69
|
+
- Breakdown by post type (photo, video, reel, carousel, link, status) with count + total/avg engagement per type
|
|
70
|
+
- Top posts by engagement
|
|
71
|
+
- A Facebook-vs-Instagram comparison block ready for the AI to narrate
|
|
72
|
+
- Optional per-post reach/views (include_insights=true)
|
|
73
|
+
|
|
74
|
+
Engagement counts (likes, comments, shares, reactions) are ALWAYS included — they come free with the post list. Reach/views/impressions need include_insights=true (one extra call per post, capped).
|
|
75
|
+
|
|
76
|
+
Walks /me/assigned_pages → Page published_posts in the window, and the linked Instagram accounts → media filtered to the window. Per-section error isolation: one failing account never blanks the report.`,
|
|
77
|
+
inputSchema: inputSchema.shape,
|
|
78
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
79
|
+
};
|
|
80
|
+
/** Normalize a Facebook post into a type + engagement total. */
|
|
81
|
+
function classifyFacebook(post) {
|
|
82
|
+
const statusType = String(post.status_type ?? "");
|
|
83
|
+
const attachments = post.attachments;
|
|
84
|
+
const mediaType = attachments?.data?.[0]?.media_type ?? "";
|
|
85
|
+
let type = "status";
|
|
86
|
+
if (mediaType === "photo" || statusType === "added_photos")
|
|
87
|
+
type = "photo";
|
|
88
|
+
else if (mediaType === "video" || statusType === "added_video")
|
|
89
|
+
type = "video";
|
|
90
|
+
else if (mediaType === "album")
|
|
91
|
+
type = "carousel";
|
|
92
|
+
else if (mediaType === "link" || statusType === "shared_story")
|
|
93
|
+
type = "link";
|
|
94
|
+
else if (statusType === "mobile_status_update")
|
|
95
|
+
type = "status";
|
|
96
|
+
const reactions = post.reactions?.summary?.total_count ?? 0;
|
|
97
|
+
const comments = post.comments?.summary?.total_count ?? 0;
|
|
98
|
+
const shares = post.shares?.count ?? 0;
|
|
99
|
+
const breakdown = { reactions, comments, shares };
|
|
100
|
+
return { type, engagement: reactions + comments + shares, breakdown };
|
|
101
|
+
}
|
|
102
|
+
/** Normalize an Instagram media into a type + engagement total. */
|
|
103
|
+
function classifyInstagram(media) {
|
|
104
|
+
const mediaType = String(media.media_type ?? "");
|
|
105
|
+
const productType = String(media.media_product_type ?? "");
|
|
106
|
+
let type = "image";
|
|
107
|
+
if (productType === "REELS")
|
|
108
|
+
type = "reel";
|
|
109
|
+
else if (mediaType === "VIDEO")
|
|
110
|
+
type = "video";
|
|
111
|
+
else if (mediaType === "CAROUSEL_ALBUM")
|
|
112
|
+
type = "carousel";
|
|
113
|
+
else if (mediaType === "IMAGE")
|
|
114
|
+
type = "image";
|
|
115
|
+
const likes = Number(media.like_count ?? 0);
|
|
116
|
+
const comments = Number(media.comments_count ?? 0);
|
|
117
|
+
const breakdown = { likes, comments };
|
|
118
|
+
return { type, engagement: likes + comments, breakdown };
|
|
119
|
+
}
|
|
120
|
+
function aggregate(posts) {
|
|
121
|
+
const byType = {};
|
|
122
|
+
let totalEngagement = 0;
|
|
123
|
+
for (const p of posts) {
|
|
124
|
+
(byType[p.type] ??= { count: 0, engagement: 0 });
|
|
125
|
+
byType[p.type].count += 1;
|
|
126
|
+
byType[p.type].engagement += p.engagement;
|
|
127
|
+
totalEngagement += p.engagement;
|
|
128
|
+
}
|
|
129
|
+
const byTypeOut = Object.fromEntries(Object.entries(byType).map(([t, v]) => [
|
|
130
|
+
t,
|
|
131
|
+
{ count: v.count, total_engagement: v.engagement, avg_engagement: Math.round((v.engagement / v.count) * 10) / 10 },
|
|
132
|
+
]));
|
|
133
|
+
return {
|
|
134
|
+
total_posts: posts.length,
|
|
135
|
+
total_engagement: totalEngagement,
|
|
136
|
+
avg_engagement_per_post: posts.length ? Math.round((totalEngagement / posts.length) * 10) / 10 : 0,
|
|
137
|
+
by_type: byTypeOut,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
export async function handler(input, ctx) {
|
|
141
|
+
const started = Date.now();
|
|
142
|
+
const now = new Date();
|
|
143
|
+
const untilDate = input.until ? new Date(input.until) : now;
|
|
144
|
+
const sinceDate = input.since
|
|
145
|
+
? new Date(input.since)
|
|
146
|
+
: new Date(untilDate.getTime() - (WINDOW_DAYS[input.date_preset] ?? 7) * 86_400_000);
|
|
147
|
+
const sinceUnix = Math.floor(sinceDate.getTime() / 1000);
|
|
148
|
+
const untilUnix = Math.floor(untilDate.getTime() / 1000);
|
|
149
|
+
const igCreds = insightsCreds(ctx.config);
|
|
150
|
+
const igOverride = igCreds ? { accessTokenOverride: igCreds.token, appSecretOverride: igCreds.appSecret } : {};
|
|
151
|
+
const posts = [];
|
|
152
|
+
const errors = {};
|
|
153
|
+
// Discover Pages.
|
|
154
|
+
let pages = [];
|
|
155
|
+
try {
|
|
156
|
+
const resp = await ctx.graph.get({
|
|
157
|
+
path: "me/assigned_pages",
|
|
158
|
+
params: { fields: "id,name", limit: input.max_pages },
|
|
159
|
+
});
|
|
160
|
+
pages = Array.isArray(resp.data) ? resp.data : [];
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
const e = err instanceof MetaError ? err : new MetaError(err.message);
|
|
164
|
+
errors["assigned_pages"] = { error: e.message, hint: e.hint };
|
|
165
|
+
}
|
|
166
|
+
// Facebook posts in window.
|
|
167
|
+
if (input.include_facebook) {
|
|
168
|
+
for (const p of pages) {
|
|
169
|
+
try {
|
|
170
|
+
const pageToken = await ctx.graph.getPageAccessToken(p.id);
|
|
171
|
+
const { data } = await ctx.graph.getAllPages({
|
|
172
|
+
path: `${p.id}/published_posts`,
|
|
173
|
+
params: {
|
|
174
|
+
fields: "id,message,created_time,permalink_url,status_type,attachments{media_type},reactions.summary(true).limit(0),comments.summary(true).limit(0),shares",
|
|
175
|
+
since: String(sinceUnix),
|
|
176
|
+
until: String(untilUnix),
|
|
177
|
+
limit: 50,
|
|
178
|
+
},
|
|
179
|
+
accessTokenOverride: pageToken,
|
|
180
|
+
}, Math.ceil(input.max_posts_per_account / 50));
|
|
181
|
+
for (const post of data.slice(0, input.max_posts_per_account)) {
|
|
182
|
+
const c = classifyFacebook(post);
|
|
183
|
+
posts.push({
|
|
184
|
+
platform: "facebook",
|
|
185
|
+
owner: p.name ?? p.id,
|
|
186
|
+
owner_id: p.id,
|
|
187
|
+
post_id: String(post.id),
|
|
188
|
+
type: c.type,
|
|
189
|
+
created: String(post.created_time ?? ""),
|
|
190
|
+
permalink: post.permalink_url,
|
|
191
|
+
caption: post.message?.slice(0, 140),
|
|
192
|
+
engagement: c.engagement,
|
|
193
|
+
engagement_breakdown: c.breakdown,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
const e = err instanceof MetaError ? err : new MetaError(err.message);
|
|
199
|
+
errors[`fb:${p.id}`] = { error: e.message, hint: e.hint };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// Instagram media in window (no native since/until — paginate then filter by timestamp).
|
|
204
|
+
if (input.include_instagram) {
|
|
205
|
+
for (const p of pages) {
|
|
206
|
+
let ig;
|
|
207
|
+
try {
|
|
208
|
+
const d = await ctx.graph.get({
|
|
209
|
+
path: p.id,
|
|
210
|
+
params: { fields: "instagram_business_account{id,username}" },
|
|
211
|
+
...igOverride,
|
|
212
|
+
});
|
|
213
|
+
ig = d.instagram_business_account;
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
/* page may have no IG; skip silently */
|
|
217
|
+
}
|
|
218
|
+
if (!ig)
|
|
219
|
+
continue;
|
|
220
|
+
try {
|
|
221
|
+
const { data } = await ctx.graph.getAllPages({
|
|
222
|
+
path: `${ig.id}/media`,
|
|
223
|
+
params: {
|
|
224
|
+
fields: "id,media_type,media_product_type,caption,permalink,timestamp,like_count,comments_count",
|
|
225
|
+
limit: 50,
|
|
226
|
+
},
|
|
227
|
+
...igOverride,
|
|
228
|
+
}, Math.ceil(input.max_posts_per_account / 50));
|
|
229
|
+
for (const media of data) {
|
|
230
|
+
const ts = Math.floor(new Date(String(media.timestamp)).getTime() / 1000);
|
|
231
|
+
if (ts < sinceUnix || ts > untilUnix)
|
|
232
|
+
continue; // window filter
|
|
233
|
+
const c = classifyInstagram(media);
|
|
234
|
+
posts.push({
|
|
235
|
+
platform: "instagram",
|
|
236
|
+
owner: ig.username ? `@${ig.username}` : ig.id,
|
|
237
|
+
owner_id: ig.id,
|
|
238
|
+
post_id: String(media.id),
|
|
239
|
+
type: c.type,
|
|
240
|
+
created: String(media.timestamp ?? ""),
|
|
241
|
+
permalink: media.permalink,
|
|
242
|
+
caption: media.caption?.slice(0, 140),
|
|
243
|
+
engagement: c.engagement,
|
|
244
|
+
engagement_breakdown: c.breakdown,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
catch (err) {
|
|
249
|
+
const e = err instanceof MetaError ? err : new MetaError(err.message);
|
|
250
|
+
errors[`ig:${ig.id}`] = { error: e.message, hint: e.hint };
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
// Optional per-post insights for the top-N by engagement per platform.
|
|
255
|
+
if (input.include_insights && posts.length > 0) {
|
|
256
|
+
const topByPlatform = (plat) => posts
|
|
257
|
+
.filter((p) => p.platform === plat)
|
|
258
|
+
.sort((a, b) => b.engagement - a.engagement)
|
|
259
|
+
.slice(0, input.insights_top_n);
|
|
260
|
+
const targets = [...topByPlatform("facebook"), ...topByPlatform("instagram")];
|
|
261
|
+
await Promise.all(targets.map(async (p) => {
|
|
262
|
+
try {
|
|
263
|
+
if (p.platform === "instagram") {
|
|
264
|
+
const r = await ctx.graph.get({
|
|
265
|
+
path: `${p.post_id}/insights`,
|
|
266
|
+
params: { metric: "reach,views,total_interactions,saved,shares" },
|
|
267
|
+
...igOverride,
|
|
268
|
+
});
|
|
269
|
+
p.insights = { data: r.data ?? [] };
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
const parentPageId = p.owner_id;
|
|
273
|
+
const creds = insightsCreds(ctx.config);
|
|
274
|
+
const pageToken = await ctx.graph.getPageAccessToken(parentPageId, creds ? { token: creds.token, appSecret: creds.appSecret } : undefined);
|
|
275
|
+
const r = await ctx.graph.get({
|
|
276
|
+
path: `${p.post_id}/insights`,
|
|
277
|
+
params: { metric: "post_impressions,post_impressions_unique,post_engaged_users" },
|
|
278
|
+
accessTokenOverride: pageToken,
|
|
279
|
+
appSecretOverride: creds?.appSecret,
|
|
280
|
+
});
|
|
281
|
+
p.insights = { data: r.data ?? [] };
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
catch (err) {
|
|
285
|
+
const e = err instanceof MetaError ? err : new MetaError(err.message);
|
|
286
|
+
p.insights = { error: e.message };
|
|
287
|
+
}
|
|
288
|
+
}));
|
|
289
|
+
}
|
|
290
|
+
const fbPosts = posts.filter((p) => p.platform === "facebook");
|
|
291
|
+
const igPosts = posts.filter((p) => p.platform === "instagram");
|
|
292
|
+
const topOverall = [...posts].sort((a, b) => b.engagement - a.engagement).slice(0, 10);
|
|
293
|
+
const structured = {
|
|
294
|
+
window: {
|
|
295
|
+
preset: input.since || input.until ? "custom" : input.date_preset,
|
|
296
|
+
since: sinceDate.toISOString(),
|
|
297
|
+
until: untilDate.toISOString(),
|
|
298
|
+
},
|
|
299
|
+
generated_at: now.toISOString(),
|
|
300
|
+
latency_ms: Date.now() - started,
|
|
301
|
+
totals: {
|
|
302
|
+
all_posts: posts.length,
|
|
303
|
+
facebook: aggregate(fbPosts),
|
|
304
|
+
instagram: aggregate(igPosts),
|
|
305
|
+
},
|
|
306
|
+
comparison: {
|
|
307
|
+
facebook_posts: fbPosts.length,
|
|
308
|
+
instagram_posts: igPosts.length,
|
|
309
|
+
facebook_total_engagement: fbPosts.reduce((s, p) => s + p.engagement, 0),
|
|
310
|
+
instagram_total_engagement: igPosts.reduce((s, p) => s + p.engagement, 0),
|
|
311
|
+
note: "Engagement = reactions+comments+shares (FB) / likes+comments (IG). For audience age/gender/country demographics, call meta_ig_get_audience_demographics (account-level).",
|
|
312
|
+
},
|
|
313
|
+
top_posts: topOverall,
|
|
314
|
+
posts,
|
|
315
|
+
errors: Object.keys(errors).length ? errors : null,
|
|
316
|
+
};
|
|
317
|
+
return toolResult(structured, jsonBlock(structured));
|
|
318
|
+
}
|
|
@@ -23,7 +23,7 @@ export type Input = z.infer<typeof inputSchema>;
|
|
|
23
23
|
export declare const definition: {
|
|
24
24
|
readonly name: "meta_latest_posts_summary";
|
|
25
25
|
readonly title: "Latest post performance across every social account";
|
|
26
|
-
readonly description: "
|
|
26
|
+
readonly description: "Single-call summary of the SINGLE most-recent post on each Facebook Page and Instagram account, with its engagement + insights.\n\nUSE FOR (latest / most-recent only):\n- \"How did my latest post perform?\"\n- \"What's the engagement on my most recent post on each account?\"\n- \"Show me the newest post on each platform.\"\n\nNOT FOR: a date range or \"how many posts this week/month\" or content comparison over a period → use meta_content_report (it takes a time window). A whole-business snapshot incl. ads/pixels → use meta_business_overview. Audience age/gender/country → use meta_ig_get_audience_demographics.\n\nInternally walks /me/assigned_pages → each Page's latest published post + optional insights → each linked Instagram account's latest media + optional insights. Auto-discovers all Page/IG IDs — the caller never supplies them. Per-section error isolation so one failure doesn't blank the result.";
|
|
27
27
|
readonly inputSchema: {
|
|
28
28
|
include_instagram: z.ZodDefault<z.ZodBoolean>;
|
|
29
29
|
include_facebook: z.ZodDefault<z.ZodBoolean>;
|
|
@@ -31,22 +31,16 @@ export const inputSchema = z
|
|
|
31
31
|
export const definition = {
|
|
32
32
|
name: "meta_latest_posts_summary",
|
|
33
33
|
title: "Latest post performance across every social account",
|
|
34
|
-
description: `
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
**Use this as the FIRST tool call** when the user asks anything like:
|
|
45
|
-
- "How are my social media accounts performing?"
|
|
46
|
-
- "What's the engagement on my latest posts?"
|
|
47
|
-
- "How did my most recent content do?"
|
|
48
|
-
|
|
49
|
-
It removes the need to discover Page / IG IDs separately, and avoids the N+1 sequence of meta_page_list → meta_page_list_posts → meta_page_get_post_insights × pages × posts.`,
|
|
34
|
+
description: `Single-call summary of the SINGLE most-recent post on each Facebook Page and Instagram account, with its engagement + insights.
|
|
35
|
+
|
|
36
|
+
USE FOR (latest / most-recent only):
|
|
37
|
+
- "How did my latest post perform?"
|
|
38
|
+
- "What's the engagement on my most recent post on each account?"
|
|
39
|
+
- "Show me the newest post on each platform."
|
|
40
|
+
|
|
41
|
+
NOT FOR: a date range or "how many posts this week/month" or content comparison over a period → use meta_content_report (it takes a time window). A whole-business snapshot incl. ads/pixels → use meta_business_overview. Audience age/gender/country → use meta_ig_get_audience_demographics.
|
|
42
|
+
|
|
43
|
+
Internally walks /me/assigned_pages → each Page's latest published post + optional insights → each linked Instagram account's latest media + optional insights. Auto-discovers all Page/IG IDs — the caller never supplies them. Per-section error isolation so one failure doesn't blank the result.`,
|
|
50
44
|
inputSchema: inputSchema.shape,
|
|
51
45
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
52
46
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { assertAllowed } from "../../config.js";
|
|
3
3
|
import { metaIdSchema } from "../../helpers/schema.js";
|
|
4
|
-
import {
|
|
4
|
+
import { runGetAsPageViaInsightsApp } from "../shared.js";
|
|
5
5
|
const PERIOD = z.enum(["day", "week", "days_28", "lifetime", "total_over_range"]);
|
|
6
6
|
export const inputSchema = z
|
|
7
7
|
.object({
|
|
@@ -37,7 +37,7 @@ export const definition = {
|
|
|
37
37
|
};
|
|
38
38
|
export async function handler(input, ctx) {
|
|
39
39
|
assertAllowed("page", input.page_id, ctx.config);
|
|
40
|
-
return
|
|
40
|
+
return runGetAsPageViaInsightsApp(ctx, input.page_id, {
|
|
41
41
|
path: `${input.page_id}/insights`,
|
|
42
42
|
params: {
|
|
43
43
|
metric: input.metrics.join(","),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { metaIdSchema } from "../../helpers/schema.js";
|
|
3
|
-
import { errorResult,
|
|
3
|
+
import { errorResult, runGetAsPageViaInsightsApp } from "../shared.js";
|
|
4
4
|
export const inputSchema = z
|
|
5
5
|
.object({
|
|
6
6
|
post_id: metaIdSchema.describe("Post ID. Meta uses the '{page_id}_{post_id}' format — pass it in full so the server can resolve the parent Page's access token automatically."),
|
|
@@ -27,8 +27,8 @@ export const inputSchema = z
|
|
|
27
27
|
export const definition = {
|
|
28
28
|
name: "meta_page_get_post_insights",
|
|
29
29
|
title: "Get insights for a single Page post",
|
|
30
|
-
description: `Reads impressions, unique reach, paid vs. organic split, engaged users, click counts, reactions-by-type, and video view metrics for a single post.
|
|
31
|
-
|
|
30
|
+
description: `Reads impressions, unique reach, paid vs. organic split, engaged users, click counts, reactions-by-type, and video view metrics for a single post.
|
|
31
|
+
|
|
32
32
|
Uses the parent Page's access token (auto-resolved from the '{page_id}_{post_id}' prefix; pass page_id explicitly if your post_id isn't in that form). Requires the system user to be assigned to the Page with 'View performance' or 'Analyze Page' tasks, plus 'read_insights' scope on the token.`,
|
|
33
33
|
inputSchema: inputSchema.shape,
|
|
34
34
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
@@ -40,7 +40,7 @@ export async function handler(input, ctx) {
|
|
|
40
40
|
if (!/^\d+$/.test(parentPageId)) {
|
|
41
41
|
return errorResult(new Error(`Could not infer parent Page ID from post_id '${input.post_id}'. Pass page_id explicitly.`));
|
|
42
42
|
}
|
|
43
|
-
return
|
|
43
|
+
return runGetAsPageViaInsightsApp(ctx, parentPageId, {
|
|
44
44
|
path: `${input.post_id}/insights`,
|
|
45
45
|
params: { metric: input.metrics.join(",") },
|
|
46
46
|
});
|
|
@@ -32,7 +32,7 @@ export type Input = z.infer<typeof inputSchema>;
|
|
|
32
32
|
export declare const definition: {
|
|
33
33
|
readonly name: "meta_page_list_posts";
|
|
34
34
|
readonly title: "List Page posts";
|
|
35
|
-
readonly description: "Lists Page posts
|
|
35
|
+
readonly description: "Lists raw Facebook Page posts for ONE page, with reaction/comment/share counts. Supports since/until for a date window and returns post IDs for meta_page_get_post_insights.\n\nUSE FOR: drilling into a single specific Page's post list, or when you already have the page_id. For a cross-account content report (\"how many posts this week across all my accounts, by type, with comparison\") prefer meta_content_report — it walks every Page + Instagram account and classifies/aggregates automatically, no page_id needed.";
|
|
36
36
|
readonly inputSchema: {
|
|
37
37
|
limit: z.ZodDefault<z.ZodNumber>;
|
|
38
38
|
after: z.ZodOptional<z.ZodString>;
|
|
@@ -39,7 +39,9 @@ export const inputSchema = z
|
|
|
39
39
|
export const definition = {
|
|
40
40
|
name: "meta_page_list_posts",
|
|
41
41
|
title: "List Page posts",
|
|
42
|
-
description: `Lists Page posts
|
|
42
|
+
description: `Lists raw Facebook Page posts for ONE page, with reaction/comment/share counts. Supports since/until for a date window and returns post IDs for meta_page_get_post_insights.
|
|
43
|
+
|
|
44
|
+
USE FOR: drilling into a single specific Page's post list, or when you already have the page_id. For a cross-account content report ("how many posts this week across all my accounts, by type, with comparison") prefer meta_content_report — it walks every Page + Instagram account and classifies/aggregates automatically, no page_id needed.`,
|
|
43
45
|
inputSchema: inputSchema.shape,
|
|
44
46
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
45
47
|
};
|
package/dist/tools/register.js
CHANGED
|
@@ -46,6 +46,7 @@ import * as whatsappGetAnalytics from "./whatsapp/get-analytics.js";
|
|
|
46
46
|
// Overview
|
|
47
47
|
import * as businessOverview from "./overview/business-overview.js";
|
|
48
48
|
import * as latestPostsSummary from "./overview/latest-posts-summary.js";
|
|
49
|
+
import * as contentReport from "./overview/content-report.js";
|
|
49
50
|
const TOOLS = [
|
|
50
51
|
// Token + meta (3)
|
|
51
52
|
tokenInspect, tokenHealth, graphRead,
|
|
@@ -65,8 +66,8 @@ const TOOLS = [
|
|
|
65
66
|
catalogList, catalogListProducts, catalogGetDiagnostics,
|
|
66
67
|
// WhatsApp (4)
|
|
67
68
|
whatsappListWabas, whatsappListPhoneNumbers, whatsappListTemplates, whatsappGetAnalytics,
|
|
68
|
-
// Overview (
|
|
69
|
-
businessOverview, latestPostsSummary,
|
|
69
|
+
// Overview (3)
|
|
70
|
+
businessOverview, latestPostsSummary, contentReport,
|
|
70
71
|
].map((m) => m);
|
|
71
72
|
export function registerTools(server, ctx) {
|
|
72
73
|
const names = [];
|
package/dist/tools/shared.d.ts
CHANGED
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared helpers used across domain tools — keeps each tool file focused on
|
|
3
|
-
* its endpoint instead of re-implementing boilerplate.
|
|
4
|
-
*/
|
|
5
1
|
import type { ToolContext } from "../context.js";
|
|
6
2
|
import { type ToolTextResult } from "../helpers/format.js";
|
|
7
3
|
import type { GraphGetOptions } from "../helpers/graph-client.js";
|
|
@@ -17,6 +13,14 @@ export interface ListRunOpts {
|
|
|
17
13
|
export declare function runList<T>(ctx: ToolContext, opts: GraphGetOptions, pag: ListRunOpts, extra?: Record<string, unknown>): Promise<ToolTextResult>;
|
|
18
14
|
/** Run a single read and return a tool result. */
|
|
19
15
|
export declare function runGet<T>(ctx: ToolContext, opts: GraphGetOptions, extra?: Record<string, unknown>): Promise<ToolTextResult>;
|
|
16
|
+
/**
|
|
17
|
+
* Like runGet, but routes through the secondary insights-app token + secret
|
|
18
|
+
* when one is configured (META_INSIGHTS_ACCESS_TOKEN). Use for direct
|
|
19
|
+
* `/{id}/insights` reads that require a permission (e.g.
|
|
20
|
+
* instagram_manage_insights) the primary ads app cannot host. Falls back to
|
|
21
|
+
* the primary token transparently when no insights app is configured.
|
|
22
|
+
*/
|
|
23
|
+
export declare function runGetViaInsightsApp<T>(ctx: ToolContext, opts: GraphGetOptions, extra?: Record<string, unknown>): Promise<ToolTextResult>;
|
|
20
24
|
export declare function errorResult(err: unknown): ToolTextResult;
|
|
21
25
|
/**
|
|
22
26
|
* Resolves a Page access token for `pageId`, then runs `runList` with it as
|
|
@@ -28,3 +32,14 @@ export declare function errorResult(err: unknown): ToolTextResult;
|
|
|
28
32
|
export declare function runListAsPage<T>(ctx: ToolContext, pageId: string, opts: GraphGetOptions, pag: ListRunOpts, extra?: Record<string, unknown>): Promise<ToolTextResult>;
|
|
29
33
|
/** Same as runGet but resolves and uses the Page access token first. */
|
|
30
34
|
export declare function runGetAsPage<T>(ctx: ToolContext, pageId: string, opts: GraphGetOptions, extra?: Record<string, unknown>): Promise<ToolTextResult>;
|
|
35
|
+
/**
|
|
36
|
+
* Page-insights variant of runGetAsPage. When a secondary insights app is
|
|
37
|
+
* configured (the app holding the Pages "read_insights" use case), the Page
|
|
38
|
+
* access token is derived from the *insights* app's token and the call's
|
|
39
|
+
* appsecret_proof uses the insights app secret. Falls back to the primary
|
|
40
|
+
* app's Page token when no insights app is configured.
|
|
41
|
+
*
|
|
42
|
+
* Use for /{page_id}/insights and /{post_id}/insights, which need
|
|
43
|
+
* `read_insights` — a permission the Marketing-API primary app cannot host.
|
|
44
|
+
*/
|
|
45
|
+
export declare function runGetAsPageViaInsightsApp<T>(ctx: ToolContext, pageId: string, opts: GraphGetOptions, extra?: Record<string, unknown>): Promise<ToolTextResult>;
|
package/dist/tools/shared.js
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers used across domain tools — keeps each tool file focused on
|
|
3
|
+
* its endpoint instead of re-implementing boilerplate.
|
|
4
|
+
*/
|
|
5
|
+
import { insightsCreds } from "../config.js";
|
|
1
6
|
import { MetaError } from "../errors.js";
|
|
2
7
|
import { jsonBlock, toolError, toolResult } from "../helpers/format.js";
|
|
8
|
+
/**
|
|
9
|
+
* If a secondary "insights app" is configured, returns the token + app-secret
|
|
10
|
+
* overrides to route this call through it; otherwise returns an empty object
|
|
11
|
+
* (call uses the primary app). Insight endpoints (IG media/audience, Page/post
|
|
12
|
+
* insights) need this because Meta forbids combining the Marketing-API use
|
|
13
|
+
* case with Instagram-content / Pages-everything on a single app — so insights
|
|
14
|
+
* frequently live on a second app. See config.ts `insightsCreds`.
|
|
15
|
+
*/
|
|
16
|
+
function insightsOverrides(ctx) {
|
|
17
|
+
const creds = insightsCreds(ctx.config);
|
|
18
|
+
if (!creds)
|
|
19
|
+
return {};
|
|
20
|
+
return { accessTokenOverride: creds.token, appSecretOverride: creds.appSecret };
|
|
21
|
+
}
|
|
3
22
|
/**
|
|
4
23
|
* Fetch one page or auto-paginate, normalize into a structured list payload,
|
|
5
24
|
* and translate errors. Use for simple `GET /{id}/{edge}` style tools.
|
|
@@ -43,6 +62,16 @@ export async function runGet(ctx, opts, extra = {}) {
|
|
|
43
62
|
return errorResult(err);
|
|
44
63
|
}
|
|
45
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Like runGet, but routes through the secondary insights-app token + secret
|
|
67
|
+
* when one is configured (META_INSIGHTS_ACCESS_TOKEN). Use for direct
|
|
68
|
+
* `/{id}/insights` reads that require a permission (e.g.
|
|
69
|
+
* instagram_manage_insights) the primary ads app cannot host. Falls back to
|
|
70
|
+
* the primary token transparently when no insights app is configured.
|
|
71
|
+
*/
|
|
72
|
+
export async function runGetViaInsightsApp(ctx, opts, extra = {}) {
|
|
73
|
+
return runGet(ctx, { ...opts, ...insightsOverrides(ctx) }, extra);
|
|
74
|
+
}
|
|
46
75
|
export function errorResult(err) {
|
|
47
76
|
const e = err instanceof MetaError ? err : new MetaError(err.message);
|
|
48
77
|
return toolError(e.message, e.hint, {
|
|
@@ -79,3 +108,30 @@ export async function runGetAsPage(ctx, pageId, opts, extra = {}) {
|
|
|
79
108
|
return errorResult(err);
|
|
80
109
|
}
|
|
81
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* Page-insights variant of runGetAsPage. When a secondary insights app is
|
|
113
|
+
* configured (the app holding the Pages "read_insights" use case), the Page
|
|
114
|
+
* access token is derived from the *insights* app's token and the call's
|
|
115
|
+
* appsecret_proof uses the insights app secret. Falls back to the primary
|
|
116
|
+
* app's Page token when no insights app is configured.
|
|
117
|
+
*
|
|
118
|
+
* Use for /{page_id}/insights and /{post_id}/insights, which need
|
|
119
|
+
* `read_insights` — a permission the Marketing-API primary app cannot host.
|
|
120
|
+
*/
|
|
121
|
+
export async function runGetAsPageViaInsightsApp(ctx, pageId, opts, extra = {}) {
|
|
122
|
+
try {
|
|
123
|
+
const creds = insightsCreds(ctx.config);
|
|
124
|
+
if (creds) {
|
|
125
|
+
const pageToken = await ctx.graph.getPageAccessToken(pageId, {
|
|
126
|
+
token: creds.token,
|
|
127
|
+
appSecret: creds.appSecret,
|
|
128
|
+
});
|
|
129
|
+
return runGet(ctx, { ...opts, accessTokenOverride: pageToken, appSecretOverride: creds.appSecret }, extra);
|
|
130
|
+
}
|
|
131
|
+
const pageToken = await ctx.graph.getPageAccessToken(pageId);
|
|
132
|
+
return runGet(ctx, { ...opts, accessTokenOverride: pageToken }, extra);
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
return errorResult(err);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -5,12 +5,12 @@ export const inputSchema = z.object({}).strict();
|
|
|
5
5
|
export const definition = {
|
|
6
6
|
name: "meta_health_check",
|
|
7
7
|
title: "Meta MCP health check",
|
|
8
|
-
description: `End-to-end reachability probe:
|
|
9
|
-
- Confirms the Graph API is reachable.
|
|
10
|
-
- Confirms the configured token resolves to a valid identity (via /me).
|
|
11
|
-
- Surfaces the latest rate-limit header snapshot from the Graph client.
|
|
12
|
-
- Lists which allowlists are active (business / ad account / page / IG user).
|
|
13
|
-
|
|
8
|
+
description: `End-to-end reachability probe:
|
|
9
|
+
- Confirms the Graph API is reachable.
|
|
10
|
+
- Confirms the configured token resolves to a valid identity (via /me).
|
|
11
|
+
- Surfaces the latest rate-limit header snapshot from the Graph client.
|
|
12
|
+
- Lists which allowlists are active (business / ad account / page / IG user).
|
|
13
|
+
|
|
14
14
|
Use when a session starts, or when diagnosing why other tools are returning errors.`,
|
|
15
15
|
inputSchema: inputSchema.shape,
|
|
16
16
|
annotations: {
|
|
@@ -34,6 +34,8 @@ export async function handler(_input, ctx) {
|
|
|
34
34
|
identity: me,
|
|
35
35
|
api_version: ctx.config.apiVersion,
|
|
36
36
|
appsecret_proof_enabled: Boolean(ctx.config.appSecret),
|
|
37
|
+
insights_app_configured: Boolean(ctx.config.insightsAccessToken),
|
|
38
|
+
insights_app_appsecret_proof_enabled: Boolean(ctx.config.insightsAppSecret),
|
|
37
39
|
rate_limit: ctx.graph.rateLimit,
|
|
38
40
|
allowlists: {
|
|
39
41
|
businesses: ctx.config.allowedBusinessIds ? [...ctx.config.allowedBusinessIds] : null,
|
|
@@ -10,17 +10,17 @@ export const inputSchema = z
|
|
|
10
10
|
export const definition = {
|
|
11
11
|
name: "meta_token_inspect",
|
|
12
12
|
title: "Inspect Meta access token",
|
|
13
|
-
description: `Decodes the configured META_ACCESS_TOKEN via Graph API /debug_token.
|
|
14
|
-
|
|
15
|
-
Returns:
|
|
16
|
-
- app_id + application name
|
|
17
|
-
- token type (system-user / user / page)
|
|
18
|
-
- is_valid
|
|
19
|
-
- expires_at + data_access_expires_at (unix seconds; 0 = never expires)
|
|
20
|
-
- scopes + granular_scopes (per-asset targeting)
|
|
21
|
-
|
|
22
|
-
Use this first when troubleshooting — almost every other tool failure traces back to a missing scope or an asset not assigned to the token.
|
|
23
|
-
|
|
13
|
+
description: `Decodes the configured META_ACCESS_TOKEN via Graph API /debug_token.
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
- app_id + application name
|
|
17
|
+
- token type (system-user / user / page)
|
|
18
|
+
- is_valid
|
|
19
|
+
- expires_at + data_access_expires_at (unix seconds; 0 = never expires)
|
|
20
|
+
- scopes + granular_scopes (per-asset targeting)
|
|
21
|
+
|
|
22
|
+
Use this first when troubleshooting — almost every other tool failure traces back to a missing scope or an asset not assigned to the token.
|
|
23
|
+
|
|
24
24
|
Never logs the token itself.`,
|
|
25
25
|
inputSchema: inputSchema.shape,
|
|
26
26
|
annotations: {
|
|
@@ -30,8 +30,8 @@ export const inputSchema = z
|
|
|
30
30
|
export const definition = {
|
|
31
31
|
name: "meta_whatsapp_get_analytics",
|
|
32
32
|
title: "Get WhatsApp Business analytics",
|
|
33
|
-
description: `Fetches WABA analytics — either the legacy 'analytics' field (message counts) or the richer 'conversation_analytics' (per-conversation pricing, categories: AUTHENTICATION/MARKETING/SERVICE/UTILITY).
|
|
34
|
-
|
|
33
|
+
description: `Fetches WABA analytics — either the legacy 'analytics' field (message counts) or the richer 'conversation_analytics' (per-conversation pricing, categories: AUTHENTICATION/MARKETING/SERVICE/UTILITY).
|
|
34
|
+
|
|
35
35
|
Pass start + end as Unix seconds. Use granularity DAY for most reports. Filter by phone_numbers, country_codes, or conversation_categories to narrow.`,
|
|
36
36
|
inputSchema: inputSchema.shape,
|
|
37
37
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|