@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.
- 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.d.ts +4 -4
- package/dist/tools/overview/business-overview.js +56 -18
- 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
|
@@ -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
|
|
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: `${
|
|
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: `${
|
|
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: `${
|
|
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: `${
|
|
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: `${
|
|
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:
|
|
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: "
|
|
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 = [];
|