@praise25/meta-mcp-server 0.1.7 → 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.
Files changed (37) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +292 -292
  3. package/dist/config.d.ts +24 -0
  4. package/dist/config.js +14 -0
  5. package/dist/constants.d.ts +1 -1
  6. package/dist/constants.js +1 -1
  7. package/dist/helpers/graph-client.d.ts +11 -1
  8. package/dist/helpers/graph-client.js +24 -16
  9. package/dist/index.js +0 -0
  10. package/dist/server.js +21 -1
  11. package/dist/tools/ads/get-insights.d.ts +1 -1
  12. package/dist/tools/ads/get-insights.js +12 -10
  13. package/dist/tools/ads/list-accounts.js +4 -4
  14. package/dist/tools/business/list-assets.js +10 -10
  15. package/dist/tools/business/list-businesses.js +4 -4
  16. package/dist/tools/business/list-system-users.js +4 -4
  17. package/dist/tools/instagram/get-audience-demographics.js +9 -9
  18. package/dist/tools/instagram/get-media-insights.js +14 -14
  19. package/dist/tools/instagram/list-accounts.js +2 -2
  20. package/dist/tools/meta/graph-read.js +7 -7
  21. package/dist/tools/overview/business-overview.d.ts +4 -4
  22. package/dist/tools/overview/business-overview.js +56 -18
  23. package/dist/tools/overview/content-report.d.ts +57 -0
  24. package/dist/tools/overview/content-report.js +318 -0
  25. package/dist/tools/overview/latest-posts-summary.d.ts +1 -1
  26. package/dist/tools/overview/latest-posts-summary.js +10 -16
  27. package/dist/tools/pages/get-insights.js +2 -2
  28. package/dist/tools/pages/get-post-insights.js +4 -4
  29. package/dist/tools/pages/list-posts.d.ts +1 -1
  30. package/dist/tools/pages/list-posts.js +3 -1
  31. package/dist/tools/register.js +3 -2
  32. package/dist/tools/shared.d.ts +19 -4
  33. package/dist/tools/shared.js +56 -0
  34. package/dist/tools/token/health.js +8 -6
  35. package/dist/tools/token/inspect.js +11 -11
  36. package/dist/tools/whatsapp/get-analytics.js +2 -2
  37. package/package.json +77 -77
@@ -5,7 +5,9 @@ import { jsonBlock, toolResult } from "../../helpers/format.js";
5
5
  import { datePresetSchema, metaIdSchema } from "../../helpers/schema.js";
6
6
  export const inputSchema = z
7
7
  .object({
8
- business_id: metaIdSchema.describe("Business Manager ID."),
8
+ business_id: metaIdSchema
9
+ .optional()
10
+ .describe("Business Manager ID. **Optional** — if omitted, the server auto-discovers via `META_ALLOWED_BUSINESS_IDS[0]` (if configured) or the first business returned by `/me/businesses`. Always prefer omitting this if you don't have the exact ID — do not guess a placeholder integer."),
9
11
  date_preset: datePresetSchema
10
12
  .default("last_30d")
11
13
  .describe("Date window used for ad account and page insights snapshots."),
@@ -19,16 +21,16 @@ export const inputSchema = z
19
21
  export const definition = {
20
22
  name: "meta_business_overview",
21
23
  title: "Eagle's-eye snapshot of a business",
22
- description: `One-call consolidated read across the whole Meta surface for a business:
23
-
24
- - Identity (system user + token app)
25
- - Assigned Pages + each Page's high-level insights (impressions, engagement, fans) for the requested window
26
- - Instagram Business accounts linked to those Pages (followers, media_count)
27
- - Owned + client ad accounts with balance, spend cap, amount spent, and last-window insights (spend, impressions, clicks, CTR, CPC, reach)
28
- - Pixels with last_fired_time (if include_pixels)
29
- - Catalogs with product counts (if include_catalogs)
30
- - WhatsApp Business Accounts + phone numbers (if include_whatsapp)
31
-
24
+ description: `One-call consolidated read across the whole Meta surface for a business:
25
+
26
+ - Identity (system user + token app)
27
+ - Assigned Pages + each Page's high-level insights (impressions, engagement, fans) for the requested window
28
+ - Instagram Business accounts linked to those Pages (followers, media_count)
29
+ - Owned + client ad accounts with balance, spend cap, amount spent, and last-window insights (spend, impressions, clicks, CTR, CPC, reach)
30
+ - Pixels with last_fired_time (if include_pixels)
31
+ - Catalogs with product counts (if include_catalogs)
32
+ - WhatsApp Business Accounts + phone numbers (if include_whatsapp)
33
+
32
34
  Each section has its own error isolation — one failing asset does not kill the report. Ideal as the opening call for any AI-driven marketing insights conversation.`,
33
35
  inputSchema: inputSchema.shape,
34
36
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
@@ -41,8 +43,43 @@ function fail(err) {
41
43
  return { ok: false, error: e.message, hint: e.hint };
42
44
  }
43
45
  export async function handler(input, ctx) {
44
- assertAllowed("business", input.business_id, ctx.config);
45
46
  const started = Date.now();
47
+ // Auto-discover business_id if not provided. Prefer the configured allowlist
48
+ // (deployment intent), fall back to /me/businesses. This makes the tool
49
+ // safe to call with no args — defeats AI-hallucinated-id failure modes.
50
+ let businessId = input.business_id;
51
+ let businessIdSource = "input";
52
+ if (!businessId) {
53
+ const allowed = ctx.config.allowedBusinessIds;
54
+ if (allowed && allowed.size > 0) {
55
+ businessId = allowed.values().next().value;
56
+ businessIdSource = "allowlist";
57
+ }
58
+ else {
59
+ try {
60
+ const list = await ctx.graph.get({
61
+ path: "me/businesses",
62
+ params: { fields: "id,name", limit: 1 },
63
+ });
64
+ const first = list.data?.[0];
65
+ if (first?.id) {
66
+ businessId = first.id;
67
+ businessIdSource = "discovered";
68
+ }
69
+ }
70
+ catch {
71
+ /* fall through — businessId remains undefined and structured response will surface the gap */
72
+ }
73
+ }
74
+ }
75
+ if (!businessId) {
76
+ return toolResult({
77
+ error: "no_business_id",
78
+ hint: "Could not determine business_id. Configure META_ALLOWED_BUSINESS_IDS in the server's environment, or pass business_id explicitly. /me/businesses returned no results for the configured token (this is normal for system-user tokens — they don't list businesses via that edge).",
79
+ suggestion: "Pass business_id directly. To discover it, an operator should run `meta_business_list_assets business_id=<known>` once with a known ID, or look up the business in Business Settings → Business Info.",
80
+ }, "{}");
81
+ }
82
+ assertAllowed("business", businessId, ctx.config);
46
83
  const token = ctx.graph.get({
47
84
  path: "debug_token",
48
85
  params: { input_token: ctx.config.accessToken },
@@ -60,14 +97,14 @@ export async function handler(input, ctx) {
60
97
  .catch(fail);
61
98
  const ownedAds = ctx.graph
62
99
  .get({
63
- path: `${input.business_id}/owned_ad_accounts`,
100
+ path: `${businessId}/owned_ad_accounts`,
64
101
  params: { fields: "id,account_id,name,currency,account_status,amount_spent,balance,spend_cap", limit: input.max_ad_accounts },
65
102
  })
66
103
  .then(ok)
67
104
  .catch(fail);
68
105
  const clientAds = ctx.graph
69
106
  .get({
70
- path: `${input.business_id}/client_ad_accounts`,
107
+ path: `${businessId}/client_ad_accounts`,
71
108
  params: { fields: "id,account_id,name,currency,account_status", limit: input.max_ad_accounts },
72
109
  })
73
110
  .then(ok)
@@ -75,7 +112,7 @@ export async function handler(input, ctx) {
75
112
  const pixels = input.include_pixels
76
113
  ? ctx.graph
77
114
  .get({
78
- path: `${input.business_id}/owned_pixels`,
115
+ path: `${businessId}/owned_pixels`,
79
116
  params: { fields: "id,name,last_fired_time,is_unavailable", limit: 25 },
80
117
  })
81
118
  .then(ok)
@@ -84,7 +121,7 @@ export async function handler(input, ctx) {
84
121
  const catalogs = input.include_catalogs
85
122
  ? ctx.graph
86
123
  .get({
87
- path: `${input.business_id}/owned_product_catalogs`,
124
+ path: `${businessId}/owned_product_catalogs`,
88
125
  params: { fields: "id,name,vertical,product_count", limit: 25 },
89
126
  })
90
127
  .then(ok)
@@ -93,7 +130,7 @@ export async function handler(input, ctx) {
93
130
  const wabas = input.include_whatsapp
94
131
  ? ctx.graph
95
132
  .get({
96
- path: `${input.business_id}/owned_whatsapp_business_accounts`,
133
+ path: `${businessId}/owned_whatsapp_business_accounts`,
97
134
  params: { fields: "id,name,currency,status,business_verification_status", limit: 25 },
98
135
  })
99
136
  .then(ok)
@@ -180,7 +217,8 @@ export async function handler(input, ctx) {
180
217
  };
181
218
  }));
182
219
  const structured = {
183
- business_id: input.business_id,
220
+ business_id: businessId,
221
+ business_id_source: businessIdSource,
184
222
  date_preset: input.date_preset,
185
223
  generated_at: new Date().toISOString(),
186
224
  latency_ms: Date.now() - started,
@@ -0,0 +1,57 @@
1
+ import { z } from "zod";
2
+ import type { ToolContext } from "../../context.js";
3
+ export declare const inputSchema: z.ZodObject<{
4
+ date_preset: z.ZodDefault<z.ZodEnum<["last_7d", "last_14d", "last_28d", "last_30d", "last_90d"]>>;
5
+ since: z.ZodOptional<z.ZodString>;
6
+ until: z.ZodOptional<z.ZodString>;
7
+ include_facebook: z.ZodDefault<z.ZodBoolean>;
8
+ include_instagram: z.ZodDefault<z.ZodBoolean>;
9
+ include_insights: z.ZodDefault<z.ZodBoolean>;
10
+ insights_top_n: z.ZodDefault<z.ZodNumber>;
11
+ max_pages: z.ZodDefault<z.ZodNumber>;
12
+ max_posts_per_account: z.ZodDefault<z.ZodNumber>;
13
+ }, "strict", z.ZodTypeAny, {
14
+ date_preset: "last_7d" | "last_14d" | "last_28d" | "last_30d" | "last_90d";
15
+ max_pages: number;
16
+ include_instagram: boolean;
17
+ include_facebook: boolean;
18
+ include_insights: boolean;
19
+ insights_top_n: number;
20
+ max_posts_per_account: number;
21
+ since?: string | undefined;
22
+ until?: string | undefined;
23
+ }, {
24
+ since?: string | undefined;
25
+ until?: string | undefined;
26
+ date_preset?: "last_7d" | "last_14d" | "last_28d" | "last_30d" | "last_90d" | undefined;
27
+ max_pages?: number | undefined;
28
+ include_instagram?: boolean | undefined;
29
+ include_facebook?: boolean | undefined;
30
+ include_insights?: boolean | undefined;
31
+ insights_top_n?: number | undefined;
32
+ max_posts_per_account?: number | undefined;
33
+ }>;
34
+ export type Input = z.infer<typeof inputSchema>;
35
+ export declare const definition: {
36
+ readonly name: "meta_content_report";
37
+ readonly title: "Content report: how many posts over a period, by type, with engagement & comparison";
38
+ readonly description: "One-call content-performance report over a TIME WINDOW (default last 7 days / this week) across all Facebook Pages and Instagram accounts.\n\nUSE FOR questions like:\n- \"How many posts have gone up in the last week / 7 days / this month?\"\n- \"Classify my posts by type (photo / video / reel / carousel).\"\n- \"Compare Facebook vs Instagram content performance.\"\n- \"Which post type drives the most engagement?\"\n- \"Give me a weekly content report / performance comparison.\"\n\nNOT 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).\n\nReturns, in one call:\n- Total post count in the window, per platform (Facebook / Instagram)\n- Breakdown by post type (photo, video, reel, carousel, link, status) with count + total/avg engagement per type\n- Top posts by engagement\n- A Facebook-vs-Instagram comparison block ready for the AI to narrate\n- Optional per-post reach/views (include_insights=true)\n\nEngagement 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).\n\nWalks /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.";
39
+ readonly inputSchema: {
40
+ date_preset: z.ZodDefault<z.ZodEnum<["last_7d", "last_14d", "last_28d", "last_30d", "last_90d"]>>;
41
+ since: z.ZodOptional<z.ZodString>;
42
+ until: z.ZodOptional<z.ZodString>;
43
+ include_facebook: z.ZodDefault<z.ZodBoolean>;
44
+ include_instagram: z.ZodDefault<z.ZodBoolean>;
45
+ include_insights: z.ZodDefault<z.ZodBoolean>;
46
+ insights_top_n: z.ZodDefault<z.ZodNumber>;
47
+ max_pages: z.ZodDefault<z.ZodNumber>;
48
+ max_posts_per_account: z.ZodDefault<z.ZodNumber>;
49
+ };
50
+ readonly annotations: {
51
+ readonly readOnlyHint: true;
52
+ readonly destructiveHint: false;
53
+ readonly idempotentHint: true;
54
+ readonly openWorldHint: true;
55
+ };
56
+ };
57
+ export declare function handler(input: Input, ctx: ToolContext): Promise<import("../../helpers/format.js").ToolTextResult>;
@@ -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: "Workflow tool that answers \"how did my latest post on each social account perform?\" in a single call.\n\nInternally walks:\n1. /me/assigned_pages every Facebook Page assigned to this token\n2. For each Page: latest published post + (optionally) its insights (impressions, reach, engaged_users, clicks)\n3. For each Page: linked instagram_business_account\n4. For each IG: latest media + (optionally) its insights (reach, views, likes, comments, shares, saved, total_interactions)\n\nReturns a unified array of '{platform, owner, latest_post, insights}' entries, plus a per-section error map so a single failure doesn't blank the report.\n\n**Use this as the FIRST tool call** when the user asks anything like:\n- \"How are my social media accounts performing?\"\n- \"What's the engagement on my latest posts?\"\n- \"How did my most recent content do?\"\n\nIt 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.";
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: `Workflow tool that answers "how did my latest post on each social account perform?" in a single call.
35
-
36
- Internally walks:
37
- 1. /me/assigned_pages every Facebook Page assigned to this token
38
- 2. For each Page: latest published post + (optionally) its insights (impressions, reach, engaged_users, clicks)
39
- 3. For each Page: linked instagram_business_account
40
- 4. For each IG: latest media + (optionally) its insights (reach, views, likes, comments, shares, saved, total_interactions)
41
-
42
- Returns a unified array of '{platform, owner, latest_post, insights}' entries, plus a per-section error map so a single failure doesn't blank the report.
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 { runGetAsPage } from "../shared.js";
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 runGetAsPage(ctx, input.page_id, {
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, runGetAsPage } from "../shared.js";
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 runGetAsPage(ctx, parentPageId, {
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 with attached counts (reactions, comments, shares). Use since/until for a date window. The ID returned for each post is the one meta_page_get_post_insights expects.";
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 with attached counts (reactions, comments, shares). Use since/until for a date window. The ID returned for each post is the one meta_page_get_post_insights expects.`,
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
  };
@@ -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 (2)
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 = [];