@praise25/meta-mcp-server 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +1 -1
- package/dist/helpers/graph-client.d.ts +24 -0
- package/dist/helpers/graph-client.js +50 -8
- package/dist/helpers/validate.d.ts +26 -0
- package/dist/helpers/validate.js +82 -0
- package/dist/tools/ads/get-insights.d.ts +1 -1
- package/dist/tools/catalog/list-products.d.ts +1 -1
- package/dist/tools/instagram/get-audience-demographics.d.ts +1 -1
- package/dist/tools/instagram/get-audience-demographics.js +6 -1
- package/dist/tools/overview/business-overview.js +21 -12
- package/dist/tools/pages/get-insights.js +2 -2
- package/dist/tools/pages/get-post-insights.d.ts +5 -1
- package/dist/tools/pages/get-post-insights.js +16 -5
- package/dist/tools/pages/list-posts.d.ts +1 -1
- package/dist/tools/pages/list-posts.js +12 -6
- package/dist/tools/pages/list-reviews.js +2 -2
- package/dist/tools/pages/list-videos.js +2 -2
- package/dist/tools/pixels/list.js +6 -3
- package/dist/tools/register.js +12 -1
- package/dist/tools/shared.d.ts +10 -0
- package/dist/tools/shared.js +26 -0
- package/dist/tools/whatsapp/list-templates.d.ts +1 -1
- package/package.json +5 -2
package/dist/constants.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ export declare const CHARACTER_LIMIT = 25000;
|
|
|
4
4
|
export declare const DEFAULT_PAGE_LIMIT = 25;
|
|
5
5
|
export declare const MAX_PAGE_LIMIT = 500;
|
|
6
6
|
export declare const SERVER_NAME = "meta-business-manager-mcp-server";
|
|
7
|
-
export declare const SERVER_VERSION = "0.1.
|
|
7
|
+
export declare const SERVER_VERSION = "0.1.2";
|
|
8
8
|
export declare const META_ERROR_CODES: {
|
|
9
9
|
readonly UNKNOWN: 1;
|
|
10
10
|
readonly SERVICE_TEMPORARILY_UNAVAILABLE: 2;
|
package/dist/constants.js
CHANGED
|
@@ -4,7 +4,7 @@ export const CHARACTER_LIMIT = 25_000;
|
|
|
4
4
|
export const DEFAULT_PAGE_LIMIT = 25;
|
|
5
5
|
export const MAX_PAGE_LIMIT = 500;
|
|
6
6
|
export const SERVER_NAME = "meta-business-manager-mcp-server";
|
|
7
|
-
export const SERVER_VERSION = "0.1.
|
|
7
|
+
export const SERVER_VERSION = "0.1.2";
|
|
8
8
|
export const META_ERROR_CODES = {
|
|
9
9
|
UNKNOWN: 1,
|
|
10
10
|
SERVICE_TEMPORARILY_UNAVAILABLE: 2,
|
|
@@ -9,6 +9,16 @@ export interface GraphGetOptions {
|
|
|
9
9
|
noCache?: boolean;
|
|
10
10
|
/** Override the API version for this call. */
|
|
11
11
|
apiVersion?: string;
|
|
12
|
+
/**
|
|
13
|
+
* Use a Page-specific access token instead of the configured system-user
|
|
14
|
+
* token for this request. Required for Page-level edges like /{page_id}/posts,
|
|
15
|
+
* /{page_id}/insights, /{page_id}/ratings, /{page_id}/videos and for post-
|
|
16
|
+
* insights endpoints. Use `getPageAccessToken(pageId)` to fetch one.
|
|
17
|
+
*
|
|
18
|
+
* `appsecret_proof` is recomputed against this token so the cryptographic
|
|
19
|
+
* binding to the app remains intact.
|
|
20
|
+
*/
|
|
21
|
+
accessTokenOverride?: string;
|
|
12
22
|
}
|
|
13
23
|
export interface GraphPagedResponse<T = unknown> {
|
|
14
24
|
data: T[];
|
|
@@ -33,10 +43,24 @@ export declare class GraphClient {
|
|
|
33
43
|
private readonly logger;
|
|
34
44
|
private readonly http;
|
|
35
45
|
private readonly cache;
|
|
46
|
+
private readonly pageTokens;
|
|
36
47
|
private lastRateLimit;
|
|
37
48
|
constructor(config: Config, logger: Logger);
|
|
38
49
|
get rateLimit(): GraphRateLimitHeaders;
|
|
39
50
|
clearCache(): void;
|
|
51
|
+
/**
|
|
52
|
+
* Resolve the Page access token for a Page id. Cached process-locally for
|
|
53
|
+
* the lifetime of the GraphClient (Page tokens don't expire as fast as
|
|
54
|
+
* user tokens, and this avoids paying the round-trip cost on every Page
|
|
55
|
+
* call). Page-level Graph edges (`/posts`, `/ratings`, `/videos`,
|
|
56
|
+
* `/insights`, `/{post_id}/insights`) require a Page access token rather
|
|
57
|
+
* than the configured system-user token; without it Meta returns
|
|
58
|
+
* `(#210) A page access token is required`.
|
|
59
|
+
*
|
|
60
|
+
* Requires the configured system user to be assigned to the Page with
|
|
61
|
+
* at least the "View performance" or "Analyze Page" task.
|
|
62
|
+
*/
|
|
63
|
+
getPageAccessToken(pageId: string): Promise<string>;
|
|
40
64
|
get<T = GraphPagedResponse>(opts: GraphGetOptions): Promise<T>;
|
|
41
65
|
/**
|
|
42
66
|
* Follow `paging.next` cursors and concatenate `data` arrays up to maxPages.
|
|
@@ -8,6 +8,7 @@ export class GraphClient {
|
|
|
8
8
|
logger;
|
|
9
9
|
http;
|
|
10
10
|
cache;
|
|
11
|
+
pageTokens = new Map();
|
|
11
12
|
lastRateLimit = {};
|
|
12
13
|
constructor(config, logger) {
|
|
13
14
|
this.config = config;
|
|
@@ -36,12 +37,46 @@ export class GraphClient {
|
|
|
36
37
|
}
|
|
37
38
|
clearCache() {
|
|
38
39
|
this.cache.clear();
|
|
40
|
+
this.pageTokens.clear();
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Resolve the Page access token for a Page id. Cached process-locally for
|
|
44
|
+
* the lifetime of the GraphClient (Page tokens don't expire as fast as
|
|
45
|
+
* user tokens, and this avoids paying the round-trip cost on every Page
|
|
46
|
+
* call). Page-level Graph edges (`/posts`, `/ratings`, `/videos`,
|
|
47
|
+
* `/insights`, `/{post_id}/insights`) require a Page access token rather
|
|
48
|
+
* than the configured system-user token; without it Meta returns
|
|
49
|
+
* `(#210) A page access token is required`.
|
|
50
|
+
*
|
|
51
|
+
* Requires the configured system user to be assigned to the Page with
|
|
52
|
+
* at least the "View performance" or "Analyze Page" task.
|
|
53
|
+
*/
|
|
54
|
+
async getPageAccessToken(pageId) {
|
|
55
|
+
if (!pageId)
|
|
56
|
+
throw new MetaError("getPageAccessToken: pageId is required");
|
|
57
|
+
const cached = this.pageTokens.get(pageId);
|
|
58
|
+
if (cached)
|
|
59
|
+
return cached;
|
|
60
|
+
const resp = await this.get({
|
|
61
|
+
path: pageId,
|
|
62
|
+
params: { fields: "access_token,id,name" },
|
|
63
|
+
// Don't cache via the URL+params LRU because the value is sensitive and
|
|
64
|
+
// we want to keep the per-page Map as the single source of truth.
|
|
65
|
+
noCache: true,
|
|
66
|
+
});
|
|
67
|
+
if (!resp.access_token) {
|
|
68
|
+
throw new MetaError(`Page ${pageId} did not return an access_token. The system user may not be assigned to this Page or lacks the required task.`, {
|
|
69
|
+
hint: "In Business Settings → System Users → AI_Insights_Reader → Add Assets, assign this Page with at least 'View performance' or 'Analyze Page' tasks.",
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
this.pageTokens.set(pageId, resp.access_token);
|
|
73
|
+
return resp.access_token;
|
|
39
74
|
}
|
|
40
75
|
async get(opts) {
|
|
41
76
|
const version = opts.apiVersion ?? this.config.apiVersion;
|
|
42
77
|
const path = opts.path.replace(/^\/+/, "");
|
|
43
78
|
const url = `/${version}/${path}`;
|
|
44
|
-
const params = this.buildParams(opts.params);
|
|
79
|
+
const params = this.buildParams(opts.params, opts.accessTokenOverride);
|
|
45
80
|
const cacheKey = this.cacheKeyFor(url, params);
|
|
46
81
|
if (!opts.noCache) {
|
|
47
82
|
const cached = this.cache.get(cacheKey);
|
|
@@ -71,7 +106,12 @@ export class GraphClient {
|
|
|
71
106
|
collected.push(...page.data);
|
|
72
107
|
nextAfter = page.paging?.cursors?.after;
|
|
73
108
|
if (page.paging?.next && pages < cap) {
|
|
74
|
-
|
|
109
|
+
// Propagate the original accessTokenOverride to subsequent pages —
|
|
110
|
+
// optsFromNextUrl strips access_token from the URL but the override
|
|
111
|
+
// (if any) must continue to be applied.
|
|
112
|
+
const next = this.optsFromNextUrl(page.paging.next);
|
|
113
|
+
next.accessTokenOverride = opts.accessTokenOverride;
|
|
114
|
+
currentOpts = next;
|
|
75
115
|
}
|
|
76
116
|
else {
|
|
77
117
|
currentOpts = null;
|
|
@@ -92,24 +132,26 @@ export class GraphClient {
|
|
|
92
132
|
}
|
|
93
133
|
return { path, params, apiVersion: version };
|
|
94
134
|
}
|
|
95
|
-
buildParams(params) {
|
|
135
|
+
buildParams(params, accessTokenOverride) {
|
|
96
136
|
const out = {};
|
|
97
137
|
for (const [k, v] of Object.entries(params ?? {})) {
|
|
98
138
|
if (v === undefined || v === null)
|
|
99
139
|
continue;
|
|
100
140
|
out[k] = v;
|
|
101
141
|
}
|
|
102
|
-
|
|
142
|
+
const token = accessTokenOverride ?? this.config.accessToken;
|
|
143
|
+
out.access_token = token;
|
|
103
144
|
if (this.config.appSecret) {
|
|
104
|
-
out.appsecret_proof = this.appsecretProof();
|
|
145
|
+
out.appsecret_proof = this.appsecretProof(token);
|
|
105
146
|
}
|
|
106
147
|
return out;
|
|
107
148
|
}
|
|
108
|
-
appsecretProof() {
|
|
109
|
-
// appsecret_proof = HMAC-SHA256(access_token, app_secret)
|
|
149
|
+
appsecretProof(token) {
|
|
150
|
+
// appsecret_proof = HMAC-SHA256(access_token, app_secret).
|
|
151
|
+
// Recompute against whichever token is in use (system-user OR Page).
|
|
110
152
|
return crypto
|
|
111
153
|
.createHmac("sha256", this.config.appSecret)
|
|
112
|
-
.update(this.config.accessToken)
|
|
154
|
+
.update(token ?? this.config.accessToken)
|
|
113
155
|
.digest("hex");
|
|
114
156
|
}
|
|
115
157
|
cacheKeyFor(url, params) {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { z, type ZodTypeAny } from "zod";
|
|
2
|
+
import { type ToolTextResult } from "./format.js";
|
|
3
|
+
/** Walk an arbitrary value and collect any string leaves that look like placeholders. */
|
|
4
|
+
export declare function findPlaceholders(value: unknown, path?: (string | number)[]): {
|
|
5
|
+
path: (string | number)[];
|
|
6
|
+
value: string;
|
|
7
|
+
}[];
|
|
8
|
+
/**
|
|
9
|
+
* Defence-in-depth input validator. Wraps a Zod object schema and:
|
|
10
|
+
* 1. Detects placeholder strings (e.g. literal "<YOUR_PAGE_ID>") with a
|
|
11
|
+
* friendly tool error explaining the AI must substitute real values.
|
|
12
|
+
* 2. Runs full Zod validation including refinements (regex, min/max, etc.).
|
|
13
|
+
* 3. Returns a structured ToolError on failure rather than throwing,
|
|
14
|
+
* so the AI gets actionable feedback instead of an opaque crash.
|
|
15
|
+
*
|
|
16
|
+
* Why this exists: some MCP gateways forward inputs to handlers without
|
|
17
|
+
* enforcing per-property Zod refinements. Without this guard, a placeholder
|
|
18
|
+
* like "act_<YOUR_PAGE_ID>" would reach Meta's API and waste a round trip.
|
|
19
|
+
*/
|
|
20
|
+
export declare function validateInput<T extends ZodTypeAny>(schema: T, args: unknown): {
|
|
21
|
+
ok: true;
|
|
22
|
+
data: z.infer<T>;
|
|
23
|
+
} | {
|
|
24
|
+
ok: false;
|
|
25
|
+
error: ToolTextResult;
|
|
26
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { toolError } from "./format.js";
|
|
2
|
+
/**
|
|
3
|
+
* Heuristics for spotting placeholder strings the AI may pass without
|
|
4
|
+
* substituting a real value:
|
|
5
|
+
* - Anything wrapped in angle brackets, e.g. "<YOUR_PAGE_ID>", "<id>"
|
|
6
|
+
* - Strings starting with `YOUR_`, `MY_`, `EXAMPLE_`
|
|
7
|
+
* - Strings ending with `_HERE`, `_PLACEHOLDER`
|
|
8
|
+
* - The literal strings "string", "id", "number" (when inside a regex slot)
|
|
9
|
+
*/
|
|
10
|
+
const PLACEHOLDER_PATTERNS = [
|
|
11
|
+
/<[^>]{1,80}>/,
|
|
12
|
+
/^YOUR[_\s-]/i,
|
|
13
|
+
/^MY[_\s-]/i,
|
|
14
|
+
/^EXAMPLE[_\s-]/i,
|
|
15
|
+
/[_\s-]HERE$/i,
|
|
16
|
+
/[_\s-]PLACEHOLDER$/i,
|
|
17
|
+
/^TODO$/i,
|
|
18
|
+
/^FIXME$/i,
|
|
19
|
+
];
|
|
20
|
+
/** Walk an arbitrary value and collect any string leaves that look like placeholders. */
|
|
21
|
+
export function findPlaceholders(value, path = []) {
|
|
22
|
+
const hits = [];
|
|
23
|
+
if (value == null)
|
|
24
|
+
return hits;
|
|
25
|
+
if (typeof value === "string") {
|
|
26
|
+
if (PLACEHOLDER_PATTERNS.some((rx) => rx.test(value))) {
|
|
27
|
+
hits.push({ path, value });
|
|
28
|
+
}
|
|
29
|
+
return hits;
|
|
30
|
+
}
|
|
31
|
+
if (Array.isArray(value)) {
|
|
32
|
+
value.forEach((v, i) => hits.push(...findPlaceholders(v, [...path, i])));
|
|
33
|
+
return hits;
|
|
34
|
+
}
|
|
35
|
+
if (typeof value === "object") {
|
|
36
|
+
for (const [k, v] of Object.entries(value)) {
|
|
37
|
+
hits.push(...findPlaceholders(v, [...path, k]));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return hits;
|
|
41
|
+
}
|
|
42
|
+
function formatPath(p) {
|
|
43
|
+
return p.length ? p.join(".") : "(root)";
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Defence-in-depth input validator. Wraps a Zod object schema and:
|
|
47
|
+
* 1. Detects placeholder strings (e.g. literal "<YOUR_PAGE_ID>") with a
|
|
48
|
+
* friendly tool error explaining the AI must substitute real values.
|
|
49
|
+
* 2. Runs full Zod validation including refinements (regex, min/max, etc.).
|
|
50
|
+
* 3. Returns a structured ToolError on failure rather than throwing,
|
|
51
|
+
* so the AI gets actionable feedback instead of an opaque crash.
|
|
52
|
+
*
|
|
53
|
+
* Why this exists: some MCP gateways forward inputs to handlers without
|
|
54
|
+
* enforcing per-property Zod refinements. Without this guard, a placeholder
|
|
55
|
+
* like "act_<YOUR_PAGE_ID>" would reach Meta's API and waste a round trip.
|
|
56
|
+
*/
|
|
57
|
+
export function validateInput(schema, args) {
|
|
58
|
+
const placeholders = findPlaceholders(args);
|
|
59
|
+
if (placeholders.length > 0) {
|
|
60
|
+
const summary = placeholders
|
|
61
|
+
.map((p) => `${formatPath(p.path)} = ${JSON.stringify(p.value)}`)
|
|
62
|
+
.join("; ");
|
|
63
|
+
return {
|
|
64
|
+
ok: false,
|
|
65
|
+
error: toolError(`Input contains placeholder values that were not substituted: ${summary}`, `Replace placeholders with real IDs / values before retrying. Examples for this server: business_id "133767790806312"; ad_account_id "act_146517954996436"; page_id "138368686823692". If you don't know the correct ID, call meta_business_list_assets first to discover available assets.`, { placeholders }),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const parsed = schema.safeParse(args);
|
|
69
|
+
if (!parsed.success) {
|
|
70
|
+
const issues = parsed.error.issues.map((iss) => ({
|
|
71
|
+
path: formatPath(iss.path),
|
|
72
|
+
message: iss.message,
|
|
73
|
+
code: iss.code,
|
|
74
|
+
}));
|
|
75
|
+
const summary = issues.map((i) => `${i.path}: ${i.message}`).join("; ");
|
|
76
|
+
return {
|
|
77
|
+
ok: false,
|
|
78
|
+
error: toolError(`Invalid input: ${summary}`, `Each parameter has its own format requirement (regex, enum, min/max). See the tool's inputSchema description for the exact expectations.`, { issues }),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
return { ok: true, data: parsed.data };
|
|
82
|
+
}
|
|
@@ -58,8 +58,8 @@ export declare const inputSchema: z.ZodObject<{
|
|
|
58
58
|
}[] | undefined;
|
|
59
59
|
}, {
|
|
60
60
|
object_id: string;
|
|
61
|
-
sort?: string[] | undefined;
|
|
62
61
|
fields?: string[] | undefined;
|
|
62
|
+
sort?: string[] | undefined;
|
|
63
63
|
limit?: number | undefined;
|
|
64
64
|
after?: string | undefined;
|
|
65
65
|
auto_paginate?: boolean | undefined;
|
|
@@ -16,8 +16,8 @@ export declare const inputSchema: z.ZodObject<{
|
|
|
16
16
|
after?: string | undefined;
|
|
17
17
|
}, {
|
|
18
18
|
catalog_id: string;
|
|
19
|
-
filter?: string | undefined;
|
|
20
19
|
fields?: string[] | undefined;
|
|
20
|
+
filter?: string | undefined;
|
|
21
21
|
limit?: number | undefined;
|
|
22
22
|
after?: string | undefined;
|
|
23
23
|
auto_paginate?: boolean | undefined;
|
|
@@ -23,7 +23,7 @@ export type Input = z.infer<typeof inputSchema>;
|
|
|
23
23
|
export declare const definition: {
|
|
24
24
|
readonly name: "meta_ig_get_audience_demographics";
|
|
25
25
|
readonly title: "Get IG audience demographics";
|
|
26
|
-
readonly description: "Reads demographic breakdown of an IG Business account's followers, engaged audience, or reached audience. Use breakdown='age'|'gender'|'country'|'city' to slice.\n\
|
|
26
|
+
readonly description: "Reads demographic breakdown of an IG Business account's followers, engaged audience, or reached audience. Use breakdown='age'|'gender'|'country'|'city' to slice.\n\n**Requirements (in order of frequency-of-failure):**\n1. Token has 'instagram_manage_insights' scope.\n2. The Hodusoft app must be approved for **Standard Access** on `instagram_manage_insights` via Meta App Review. In development tier, Meta returns `(#10) Application does not have permission for this action` on this endpoint specifically — even if the scope is on the token.\n3. The IG Business account must have ≥ 100 followers (Meta hides smaller accounts' demographics for privacy).\n\nIf you see code (#10), it's almost always #2. Track the App Review request and surface the gap to the operator; this tool is functioning correctly when it returns that error.";
|
|
27
27
|
readonly inputSchema: {
|
|
28
28
|
ig_user_id: z.ZodString;
|
|
29
29
|
metric: z.ZodDefault<z.ZodEnum<["follower_demographics", "engaged_audience_demographics", "reached_audience_demographics"]>>;
|
|
@@ -28,7 +28,12 @@ export const definition = {
|
|
|
28
28
|
title: "Get IG audience demographics",
|
|
29
29
|
description: `Reads demographic breakdown of an IG Business account's followers, engaged audience, or reached audience. Use breakdown='age'|'gender'|'country'|'city' to slice.
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
**Requirements (in order of frequency-of-failure):**
|
|
32
|
+
1. Token has 'instagram_manage_insights' scope.
|
|
33
|
+
2. The Hodusoft app must be approved for **Standard Access** on \`instagram_manage_insights\` via Meta App Review. In development tier, Meta returns \`(#10) Application does not have permission for this action\` on this endpoint specifically — even if the scope is on the token.
|
|
34
|
+
3. The IG Business account must have ≥ 100 followers (Meta hides smaller accounts' demographics for privacy).
|
|
35
|
+
|
|
36
|
+
If you see code (#10), it's almost always #2. Track the App Review request and surface the gap to the operator; this tool is functioning correctly when it returns that error.`,
|
|
32
37
|
inputSchema: inputSchema.shape,
|
|
33
38
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
34
39
|
};
|
|
@@ -110,17 +110,22 @@ export async function handler(input, ctx) {
|
|
|
110
110
|
wabas,
|
|
111
111
|
]);
|
|
112
112
|
// Expand each page with linked IG + a small insights fetch.
|
|
113
|
+
// Page insights require the per-Page access token, so resolve it lazily.
|
|
113
114
|
const pagesExpanded = pagesR.ok && pagesR.data.data
|
|
114
115
|
? await Promise.all(pagesR.data.data.map(async (p) => {
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
116
|
+
const igR = await ctx.graph
|
|
117
|
+
.get({
|
|
118
|
+
path: p.id,
|
|
119
|
+
params: { fields: "instagram_business_account{id,username,name,followers_count,media_count}" },
|
|
120
|
+
})
|
|
121
|
+
.then(ok)
|
|
122
|
+
.catch(fail);
|
|
123
|
+
// Resolve Page access token for the insights call. If we can't,
|
|
124
|
+
// record a per-page error rather than blanking the whole report.
|
|
125
|
+
let insightsR;
|
|
126
|
+
try {
|
|
127
|
+
const pageToken = await ctx.graph.getPageAccessToken(p.id);
|
|
128
|
+
insightsR = await ctx.graph
|
|
124
129
|
.get({
|
|
125
130
|
path: `${p.id}/insights`,
|
|
126
131
|
params: {
|
|
@@ -128,10 +133,14 @@ export async function handler(input, ctx) {
|
|
|
128
133
|
metric: "page_impressions,page_post_engagements,page_fans,page_views_total",
|
|
129
134
|
date_preset: input.date_preset,
|
|
130
135
|
},
|
|
136
|
+
accessTokenOverride: pageToken,
|
|
131
137
|
})
|
|
132
|
-
.then(ok)
|
|
133
|
-
.catch(fail)
|
|
134
|
-
|
|
138
|
+
.then((d) => ok({ data: d.data }))
|
|
139
|
+
.catch(fail);
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
insightsR = fail(err);
|
|
143
|
+
}
|
|
135
144
|
return {
|
|
136
145
|
id: p.id,
|
|
137
146
|
name: p.name,
|
|
@@ -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 { runGetAsPage } 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 runGetAsPage(ctx, input.page_id, {
|
|
41
41
|
path: `${input.page_id}/insights`,
|
|
42
42
|
params: {
|
|
43
43
|
metric: input.metrics.join(","),
|
|
@@ -2,21 +2,25 @@ import { z } from "zod";
|
|
|
2
2
|
import type { ToolContext } from "../../context.js";
|
|
3
3
|
export declare const inputSchema: z.ZodObject<{
|
|
4
4
|
post_id: z.ZodString;
|
|
5
|
+
page_id: z.ZodOptional<z.ZodString>;
|
|
5
6
|
metrics: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
6
7
|
}, "strict", z.ZodTypeAny, {
|
|
7
8
|
post_id: string;
|
|
8
9
|
metrics: string[];
|
|
10
|
+
page_id?: string | undefined;
|
|
9
11
|
}, {
|
|
10
12
|
post_id: string;
|
|
13
|
+
page_id?: string | undefined;
|
|
11
14
|
metrics?: string[] | undefined;
|
|
12
15
|
}>;
|
|
13
16
|
export type Input = z.infer<typeof inputSchema>;
|
|
14
17
|
export declare const definition: {
|
|
15
18
|
readonly name: "meta_page_get_post_insights";
|
|
16
19
|
readonly title: "Get insights for a single Page post";
|
|
17
|
-
readonly description: "Reads impressions, unique reach, paid vs. organic split, engaged users, click counts, reactions-by-type, and video view metrics for a single post. Requires 'read_insights' scope.";
|
|
20
|
+
readonly description: "Reads impressions, unique reach, paid vs. organic split, engaged users, click counts, reactions-by-type, and video view metrics for a single post.\n\nUses 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.";
|
|
18
21
|
readonly inputSchema: {
|
|
19
22
|
post_id: z.ZodString;
|
|
23
|
+
page_id: z.ZodOptional<z.ZodString>;
|
|
20
24
|
metrics: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
21
25
|
};
|
|
22
26
|
readonly annotations: {
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { metaIdSchema } from "../../helpers/schema.js";
|
|
3
|
-
import {
|
|
3
|
+
import { errorResult, runGetAsPage } from "../shared.js";
|
|
4
4
|
export const inputSchema = z
|
|
5
5
|
.object({
|
|
6
|
-
post_id: metaIdSchema.describe("Post ID
|
|
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."),
|
|
7
|
+
page_id: metaIdSchema
|
|
8
|
+
.optional()
|
|
9
|
+
.describe("Optional explicit Page ID. Use when the post_id is not in the '{page_id}_{post_id}' form (rare)."),
|
|
7
10
|
metrics: z
|
|
8
11
|
.array(z.string())
|
|
9
12
|
.default([
|
|
@@ -18,18 +21,26 @@ export const inputSchema = z
|
|
|
18
21
|
"post_video_views_unique",
|
|
19
22
|
"post_video_avg_time_watched",
|
|
20
23
|
])
|
|
21
|
-
.describe("Post-level insight metrics. See https://developers.facebook.com/docs/graph-api/reference/v23.0/insights"),
|
|
24
|
+
.describe("Post-level insight metrics. See https://developers.facebook.com/docs/graph-api/reference/v23.0/insights. Requires 'read_insights' scope. Note: Meta will reject the call if any single metric is invalid for the post type — narrow the list if you see (#100)."),
|
|
22
25
|
})
|
|
23
26
|
.strict();
|
|
24
27
|
export const definition = {
|
|
25
28
|
name: "meta_page_get_post_insights",
|
|
26
29
|
title: "Get insights for a single Page post",
|
|
27
|
-
description: `Reads impressions, unique reach, paid vs. organic split, engaged users, click counts, reactions-by-type, and video view metrics for a single 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
|
+
|
|
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.`,
|
|
28
33
|
inputSchema: inputSchema.shape,
|
|
29
34
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
30
35
|
};
|
|
31
36
|
export async function handler(input, ctx) {
|
|
32
|
-
|
|
37
|
+
// Resolve the parent Page id either from an explicit input or from the
|
|
38
|
+
// '{page_id}_{post_id}' prefix of the post_id.
|
|
39
|
+
const parentPageId = input.page_id ?? input.post_id.split("_")[0];
|
|
40
|
+
if (!/^\d+$/.test(parentPageId)) {
|
|
41
|
+
return errorResult(new Error(`Could not infer parent Page ID from post_id '${input.post_id}'. Pass page_id explicitly.`));
|
|
42
|
+
}
|
|
43
|
+
return runGetAsPage(ctx, parentPageId, {
|
|
33
44
|
path: `${input.post_id}/insights`,
|
|
34
45
|
params: { metric: input.metrics.join(",") },
|
|
35
46
|
});
|
|
@@ -20,9 +20,9 @@ export declare const inputSchema: z.ZodObject<{
|
|
|
20
20
|
after?: string | undefined;
|
|
21
21
|
}, {
|
|
22
22
|
page_id: string;
|
|
23
|
+
fields?: string[] | undefined;
|
|
23
24
|
since?: string | undefined;
|
|
24
25
|
until?: string | undefined;
|
|
25
|
-
fields?: string[] | undefined;
|
|
26
26
|
limit?: number | undefined;
|
|
27
27
|
after?: string | undefined;
|
|
28
28
|
auto_paginate?: boolean | undefined;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { assertAllowed } from "../../config.js";
|
|
3
3
|
import { metaIdSchema, paginationShape } from "../../helpers/schema.js";
|
|
4
|
-
import {
|
|
4
|
+
import { runListAsPage } from "../shared.js";
|
|
5
5
|
export const inputSchema = z
|
|
6
6
|
.object({
|
|
7
7
|
page_id: metaIdSchema,
|
|
@@ -14,21 +14,25 @@ export const inputSchema = z
|
|
|
14
14
|
fields: z
|
|
15
15
|
.array(z.string())
|
|
16
16
|
.default([
|
|
17
|
+
// Note: 'subattachments' is deprecated as of Graph API v3.3+ for
|
|
18
|
+
// aggregate field expansion (#12 deprecate_post_aggregated_fields_for_attachement).
|
|
19
|
+
// Removed from default. Likewise 'is_published' and 'type' are
|
|
20
|
+
// deprecated for some post kinds — kept out of the default to keep
|
|
21
|
+
// the call shape valid across all post types. Re-add via the fields
|
|
22
|
+
// input if you need them and have verified the call still passes.
|
|
17
23
|
"id",
|
|
18
24
|
"message",
|
|
19
25
|
"created_time",
|
|
20
26
|
"updated_time",
|
|
21
27
|
"permalink_url",
|
|
22
28
|
"status_type",
|
|
23
|
-
"type",
|
|
24
|
-
"is_published",
|
|
25
29
|
"from",
|
|
26
|
-
"attachments{media_type,title,url
|
|
30
|
+
"attachments{media_type,title,url}",
|
|
27
31
|
"reactions.summary(true).limit(0)",
|
|
28
32
|
"shares",
|
|
29
33
|
"comments.summary(true).limit(0)",
|
|
30
34
|
])
|
|
31
|
-
.describe("Post fields. Default includes reaction + comment counts via summary."),
|
|
35
|
+
.describe("Post fields. Default includes reaction + comment counts via summary. 'subattachments', 'is_published' and 'type' are excluded by default — Meta deprecated them on certain post types and including them rejects the whole call."),
|
|
32
36
|
...paginationShape,
|
|
33
37
|
})
|
|
34
38
|
.strict();
|
|
@@ -41,7 +45,9 @@ export const definition = {
|
|
|
41
45
|
};
|
|
42
46
|
export async function handler(input, ctx) {
|
|
43
47
|
assertAllowed("page", input.page_id, ctx.config);
|
|
44
|
-
|
|
48
|
+
// Page-level edges (/posts, /published_posts, /feed) require a Page access
|
|
49
|
+
// token, not the system-user token. runListAsPage resolves it first.
|
|
50
|
+
return runListAsPage(ctx, input.page_id, {
|
|
45
51
|
path: `${input.page_id}/${input.edge}`,
|
|
46
52
|
params: {
|
|
47
53
|
fields: input.fields.join(","),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { assertAllowed } from "../../config.js";
|
|
3
3
|
import { metaIdSchema, paginationShape } from "../../helpers/schema.js";
|
|
4
|
-
import {
|
|
4
|
+
import { runListAsPage } from "../shared.js";
|
|
5
5
|
export const inputSchema = z
|
|
6
6
|
.object({
|
|
7
7
|
page_id: metaIdSchema,
|
|
@@ -30,7 +30,7 @@ export const definition = {
|
|
|
30
30
|
};
|
|
31
31
|
export async function handler(input, ctx) {
|
|
32
32
|
assertAllowed("page", input.page_id, ctx.config);
|
|
33
|
-
return
|
|
33
|
+
return runListAsPage(ctx, input.page_id, {
|
|
34
34
|
path: `${input.page_id}/ratings`,
|
|
35
35
|
params: { fields: input.fields.join(","), limit: input.limit, after: input.after },
|
|
36
36
|
}, { auto_paginate: input.auto_paginate, after: input.after, limit: input.limit }, { page_id: input.page_id });
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { assertAllowed } from "../../config.js";
|
|
3
3
|
import { metaIdSchema, paginationShape } from "../../helpers/schema.js";
|
|
4
|
-
import {
|
|
4
|
+
import { runListAsPage } from "../shared.js";
|
|
5
5
|
export const inputSchema = z
|
|
6
6
|
.object({
|
|
7
7
|
page_id: metaIdSchema,
|
|
@@ -33,7 +33,7 @@ export const definition = {
|
|
|
33
33
|
};
|
|
34
34
|
export async function handler(input, ctx) {
|
|
35
35
|
assertAllowed("page", input.page_id, ctx.config);
|
|
36
|
-
return
|
|
36
|
+
return runListAsPage(ctx, input.page_id, {
|
|
37
37
|
path: `${input.page_id}/videos`,
|
|
38
38
|
params: { fields: input.fields.join(","), limit: input.limit, after: input.after },
|
|
39
39
|
}, { auto_paginate: input.auto_paginate, after: input.after, limit: input.limit }, { page_id: input.page_id });
|
|
@@ -8,17 +8,20 @@ export const inputSchema = z
|
|
|
8
8
|
fields: z
|
|
9
9
|
.array(z.string())
|
|
10
10
|
.default([
|
|
11
|
+
// 'code' (the pixel's JS snippet) and 'owner_ad_account' both require
|
|
12
|
+
// ads_management on the *owning* ad account, not just the business.
|
|
13
|
+
// Reading them with a system-user token that wasn't granted that
|
|
14
|
+
// task fails the whole list with (#200). We omit them from the
|
|
15
|
+
// defaults so the list stays useful; callers can re-add explicitly.
|
|
11
16
|
"id",
|
|
12
17
|
"name",
|
|
13
|
-
"code",
|
|
14
18
|
"is_created_by_business",
|
|
15
19
|
"is_unavailable",
|
|
16
20
|
"last_fired_time",
|
|
17
21
|
"creation_time",
|
|
18
|
-
"owner_ad_account",
|
|
19
22
|
"owner_business",
|
|
20
23
|
])
|
|
21
|
-
.describe("Pixel fields."),
|
|
24
|
+
.describe("Pixel fields. 'code' and 'owner_ad_account' are NOT in the default — they require ads_management on the owning ad account. Re-add them only when the system user has that task on every pixel's owner account."),
|
|
22
25
|
...paginationShape,
|
|
23
26
|
})
|
|
24
27
|
.strict();
|
package/dist/tools/register.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { validateInput } from "../helpers/validate.js";
|
|
1
3
|
// Token + meta
|
|
2
4
|
import * as tokenInspect from "./token/inspect.js";
|
|
3
5
|
import * as tokenHealth from "./token/health.js";
|
|
@@ -74,7 +76,16 @@ export function registerTools(server, ctx) {
|
|
|
74
76
|
inputSchema: mod.definition.inputSchema,
|
|
75
77
|
annotations: mod.definition.annotations,
|
|
76
78
|
}, (async (args) => {
|
|
77
|
-
|
|
79
|
+
// Defense in depth: re-validate at the handler boundary even if the
|
|
80
|
+
// SDK / MCP gateway already did. Some gateways forward inputs without
|
|
81
|
+
// enforcing per-property Zod refinements (e.g. regex on ad_account_id),
|
|
82
|
+
// which would otherwise let placeholder strings like "act_<YOUR_PAGE_ID>"
|
|
83
|
+
// reach Meta. See ADR-20260429-Handler-Input-Validation.md.
|
|
84
|
+
const validated = validateInput(mod.inputSchema ?? z.object(mod.definition.inputSchema), args);
|
|
85
|
+
if (!validated.ok) {
|
|
86
|
+
return validated.error;
|
|
87
|
+
}
|
|
88
|
+
return mod.handler(validated.data, ctx);
|
|
78
89
|
}));
|
|
79
90
|
names.push(mod.definition.name);
|
|
80
91
|
}
|
package/dist/tools/shared.d.ts
CHANGED
|
@@ -18,3 +18,13 @@ export declare function runList<T>(ctx: ToolContext, opts: GraphGetOptions, pag:
|
|
|
18
18
|
/** Run a single read and return a tool result. */
|
|
19
19
|
export declare function runGet<T>(ctx: ToolContext, opts: GraphGetOptions, extra?: Record<string, unknown>): Promise<ToolTextResult>;
|
|
20
20
|
export declare function errorResult(err: unknown): ToolTextResult;
|
|
21
|
+
/**
|
|
22
|
+
* Resolves a Page access token for `pageId`, then runs `runList` with it as
|
|
23
|
+
* the access-token override. Page-level edges (`/{page_id}/posts`,
|
|
24
|
+
* `/{page_id}/insights`, `/{page_id}/ratings`, `/{page_id}/videos`,
|
|
25
|
+
* `/{post_id}/insights`) require a Page access token rather than the
|
|
26
|
+
* configured system-user token.
|
|
27
|
+
*/
|
|
28
|
+
export declare function runListAsPage<T>(ctx: ToolContext, pageId: string, opts: GraphGetOptions, pag: ListRunOpts, extra?: Record<string, unknown>): Promise<ToolTextResult>;
|
|
29
|
+
/** Same as runGet but resolves and uses the Page access token first. */
|
|
30
|
+
export declare function runGetAsPage<T>(ctx: ToolContext, pageId: string, opts: GraphGetOptions, extra?: Record<string, unknown>): Promise<ToolTextResult>;
|
package/dist/tools/shared.js
CHANGED
|
@@ -53,3 +53,29 @@ export function errorResult(err) {
|
|
|
53
53
|
retryable: e.retryable,
|
|
54
54
|
});
|
|
55
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* Resolves a Page access token for `pageId`, then runs `runList` with it as
|
|
58
|
+
* the access-token override. Page-level edges (`/{page_id}/posts`,
|
|
59
|
+
* `/{page_id}/insights`, `/{page_id}/ratings`, `/{page_id}/videos`,
|
|
60
|
+
* `/{post_id}/insights`) require a Page access token rather than the
|
|
61
|
+
* configured system-user token.
|
|
62
|
+
*/
|
|
63
|
+
export async function runListAsPage(ctx, pageId, opts, pag, extra = {}) {
|
|
64
|
+
try {
|
|
65
|
+
const pageToken = await ctx.graph.getPageAccessToken(pageId);
|
|
66
|
+
return runList(ctx, { ...opts, accessTokenOverride: pageToken }, pag, extra);
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
return errorResult(err);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** Same as runGet but resolves and uses the Page access token first. */
|
|
73
|
+
export async function runGetAsPage(ctx, pageId, opts, extra = {}) {
|
|
74
|
+
try {
|
|
75
|
+
const pageToken = await ctx.graph.getPageAccessToken(pageId);
|
|
76
|
+
return runGet(ctx, { ...opts, accessTokenOverride: pageToken }, extra);
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
return errorResult(err);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -16,8 +16,8 @@ export declare const inputSchema: z.ZodObject<{
|
|
|
16
16
|
after?: string | undefined;
|
|
17
17
|
}, {
|
|
18
18
|
waba_id: string;
|
|
19
|
-
status?: "PAUSED" | "DELETED" | "APPROVED" | "PENDING" | "REJECTED" | "DISABLED" | "IN_APPEAL" | undefined;
|
|
20
19
|
fields?: string[] | undefined;
|
|
20
|
+
status?: "PAUSED" | "DELETED" | "APPROVED" | "PENDING" | "REJECTED" | "DISABLED" | "IN_APPEAL" | undefined;
|
|
21
21
|
limit?: number | undefined;
|
|
22
22
|
after?: string | undefined;
|
|
23
23
|
auto_paginate?: boolean | undefined;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@praise25/meta-mcp-server",
|
|
3
3
|
"description": "Read-only Model Context Protocol server for Meta Business Manager — Pages, Instagram, Ads insights, Pixels, Catalog, WhatsApp.",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.2",
|
|
5
5
|
"author": "Stephen A.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"homepage": "https://github.com/feladeveloper/meta-mcp-server#readme",
|
|
@@ -52,8 +52,11 @@
|
|
|
52
52
|
"inspect": "npm run build && npx @modelcontextprotocol/inspector dist/index.js",
|
|
53
53
|
"check:types": "tsc --noEmit --project tsconfig.json",
|
|
54
54
|
"test:readonly": "npm run build && node tests/read-only-guard.mjs",
|
|
55
|
+
"test:placeholder": "npm run build && node tests/placeholder-rejection.mjs",
|
|
56
|
+
"test:invariants": "npm run test:readonly && npm run test:placeholder",
|
|
57
|
+
"test:scenarios": "npm run build && node tests/scenarios.mjs",
|
|
55
58
|
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
|
|
56
|
-
"prepublishOnly": "npm run check:types && npm run test:
|
|
59
|
+
"prepublishOnly": "npm run check:types && npm run test:invariants"
|
|
57
60
|
},
|
|
58
61
|
"dependencies": {
|
|
59
62
|
"@modelcontextprotocol/sdk": "^1.11.2",
|