@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.
@@ -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.0";
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.0";
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
- currentOpts = this.optsFromNextUrl(page.paging.next);
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
- out.access_token = this.config.accessToken;
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\nRequires 'instagram_manage_insights'. Only IG Business accounts with at least 100 followers return data below that threshold Meta returns empty results for privacy.";
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
- Requires 'instagram_manage_insights'. Only IG Business accounts with at least 100 followers return data — below that threshold Meta returns empty results for privacy.`,
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 [igR, insightsR] = await Promise.all([
116
- 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
- ctx.graph
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 { runGet } from "../shared.js";
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 runGet(ctx, {
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 { runGet } from "../shared.js";
3
+ import { errorResult, runGetAsPage } from "../shared.js";
4
4
  export const inputSchema = z
5
5
  .object({
6
- post_id: metaIdSchema.describe("Post ID (format '{page_id}_{post_id}' or shortform)."),
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. Requires 'read_insights' scope.`,
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
- return runGet(ctx, {
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 { runList } from "../shared.js";
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,subattachments}",
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
- return runList(ctx, {
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 { runList } from "../shared.js";
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 runList(ctx, {
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 { runList } from "../shared.js";
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 runList(ctx, {
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();
@@ -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
- return mod.handler(args, ctx);
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
  }
@@ -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>;
@@ -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.0",
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:readonly"
59
+ "prepublishOnly": "npm run check:types && npm run test:invariants"
57
60
  },
58
61
  "dependencies": {
59
62
  "@modelcontextprotocol/sdk": "^1.11.2",