@cashtokenai/meta-mcp-server 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +294 -0
  3. package/dist/config.d.ts +39 -0
  4. package/dist/config.js +60 -0
  5. package/dist/constants.d.ts +21 -0
  6. package/dist/constants.js +28 -0
  7. package/dist/context.d.ts +9 -0
  8. package/dist/context.js +8 -0
  9. package/dist/errors.d.ts +41 -0
  10. package/dist/errors.js +120 -0
  11. package/dist/helpers/cache.d.ts +6 -0
  12. package/dist/helpers/cache.js +28 -0
  13. package/dist/helpers/format.d.ts +17 -0
  14. package/dist/helpers/format.js +28 -0
  15. package/dist/helpers/graph-client.d.ts +90 -0
  16. package/dist/helpers/graph-client.js +219 -0
  17. package/dist/helpers/schema.d.ts +30 -0
  18. package/dist/helpers/schema.js +69 -0
  19. package/dist/helpers/validate.d.ts +26 -0
  20. package/dist/helpers/validate.js +125 -0
  21. package/dist/index.d.ts +2 -0
  22. package/dist/index.js +36 -0
  23. package/dist/logger.d.ts +3 -0
  24. package/dist/logger.js +18 -0
  25. package/dist/server.d.ts +7 -0
  26. package/dist/server.js +34 -0
  27. package/dist/tools/ads/get-account.d.ts +29 -0
  28. package/dist/tools/ads/get-account.js +45 -0
  29. package/dist/tools/ads/get-creative.d.ts +29 -0
  30. package/dist/tools/ads/get-creative.js +37 -0
  31. package/dist/tools/ads/get-insights.d.ts +129 -0
  32. package/dist/tools/ads/get-insights.js +153 -0
  33. package/dist/tools/ads/list-accounts.d.ts +54 -0
  34. package/dist/tools/ads/list-accounts.js +59 -0
  35. package/dist/tools/ads/list-ads.d.ts +53 -0
  36. package/dist/tools/ads/list-ads.js +59 -0
  37. package/dist/tools/ads/list-adsets.d.ts +49 -0
  38. package/dist/tools/ads/list-adsets.js +54 -0
  39. package/dist/tools/ads/list-campaigns.d.ts +45 -0
  40. package/dist/tools/ads/list-campaigns.js +64 -0
  41. package/dist/tools/ads/list-custom-audiences.d.ts +41 -0
  42. package/dist/tools/ads/list-custom-audiences.js +41 -0
  43. package/dist/tools/business/list-assets.d.ts +37 -0
  44. package/dist/tools/business/list-assets.js +136 -0
  45. package/dist/tools/business/list-businesses.d.ts +37 -0
  46. package/dist/tools/business/list-businesses.js +81 -0
  47. package/dist/tools/business/list-system-users.d.ts +41 -0
  48. package/dist/tools/business/list-system-users.js +73 -0
  49. package/dist/tools/catalog/get-diagnostics.d.ts +29 -0
  50. package/dist/tools/catalog/get-diagnostics.js +26 -0
  51. package/dist/tools/catalog/list-products.d.ts +45 -0
  52. package/dist/tools/catalog/list-products.js +49 -0
  53. package/dist/tools/catalog/list.d.ts +54 -0
  54. package/dist/tools/catalog/list.js +48 -0
  55. package/dist/tools/instagram/get-account.d.ts +29 -0
  56. package/dist/tools/instagram/get-account.js +34 -0
  57. package/dist/tools/instagram/get-audience-demographics.d.ts +45 -0
  58. package/dist/tools/instagram/get-audience-demographics.js +58 -0
  59. package/dist/tools/instagram/get-media-insights.d.ts +29 -0
  60. package/dist/tools/instagram/get-media-insights.js +49 -0
  61. package/dist/tools/instagram/list-accounts.d.ts +33 -0
  62. package/dist/tools/instagram/list-accounts.js +63 -0
  63. package/dist/tools/instagram/list-media.d.ts +41 -0
  64. package/dist/tools/instagram/list-media.js +42 -0
  65. package/dist/tools/meta/graph-read.d.ts +33 -0
  66. package/dist/tools/meta/graph-read.js +71 -0
  67. package/dist/tools/overview/business-overview.d.ts +49 -0
  68. package/dist/tools/overview/business-overview.js +235 -0
  69. package/dist/tools/overview/content-report.d.ts +57 -0
  70. package/dist/tools/overview/content-report.js +344 -0
  71. package/dist/tools/overview/latest-posts-summary.d.ts +41 -0
  72. package/dist/tools/overview/latest-posts-summary.js +189 -0
  73. package/dist/tools/pages/get-insights.d.ts +41 -0
  74. package/dist/tools/pages/get-insights.js +49 -0
  75. package/dist/tools/pages/get-post-insights.d.ts +33 -0
  76. package/dist/tools/pages/get-post-insights.js +47 -0
  77. package/dist/tools/pages/get.d.ts +29 -0
  78. package/dist/tools/pages/get.js +50 -0
  79. package/dist/tools/pages/list-posts.d.ts +53 -0
  80. package/dist/tools/pages/list-posts.js +62 -0
  81. package/dist/tools/pages/list-reviews.d.ts +41 -0
  82. package/dist/tools/pages/list-reviews.js +37 -0
  83. package/dist/tools/pages/list-videos.d.ts +41 -0
  84. package/dist/tools/pages/list-videos.js +40 -0
  85. package/dist/tools/pages/list.d.ts +41 -0
  86. package/dist/tools/pages/list.js +39 -0
  87. package/dist/tools/pixels/get-stats.d.ts +41 -0
  88. package/dist/tools/pixels/get-stats.js +34 -0
  89. package/dist/tools/pixels/list.d.ts +41 -0
  90. package/dist/tools/pixels/list.js +41 -0
  91. package/dist/tools/register.d.ts +3 -0
  92. package/dist/tools/register.js +95 -0
  93. package/dist/tools/shared.d.ts +45 -0
  94. package/dist/tools/shared.js +137 -0
  95. package/dist/tools/token/health.d.ts +17 -0
  96. package/dist/tools/token/health.js +65 -0
  97. package/dist/tools/token/inspect.d.ts +26 -0
  98. package/dist/tools/token/inspect.js +88 -0
  99. package/dist/tools/whatsapp/get-analytics.d.ts +57 -0
  100. package/dist/tools/whatsapp/get-analytics.js +66 -0
  101. package/dist/tools/whatsapp/list-phone-numbers.d.ts +41 -0
  102. package/dist/tools/whatsapp/list-phone-numbers.js +35 -0
  103. package/dist/tools/whatsapp/list-templates.d.ts +45 -0
  104. package/dist/tools/whatsapp/list-templates.js +44 -0
  105. package/dist/tools/whatsapp/list-wabas.d.ts +54 -0
  106. package/dist/tools/whatsapp/list-wabas.js +48 -0
  107. package/dist/types/meta.d.ts +46 -0
  108. package/dist/types/meta.js +1 -0
  109. package/package.json +77 -0
@@ -0,0 +1,28 @@
1
+ import { LRUCache } from "lru-cache";
2
+ class NoopCache {
3
+ get() {
4
+ return undefined;
5
+ }
6
+ set() {
7
+ /* noop */
8
+ }
9
+ clear() {
10
+ /* noop */
11
+ }
12
+ }
13
+ export function createCache(ttlSeconds) {
14
+ if (ttlSeconds <= 0)
15
+ return new NoopCache();
16
+ const cache = new LRUCache({
17
+ max: 500,
18
+ ttl: ttlSeconds * 1000,
19
+ });
20
+ return {
21
+ get: (k) => cache.get(k),
22
+ set: (k, v) => {
23
+ if (v != null && typeof v === "object")
24
+ cache.set(k, v);
25
+ },
26
+ clear: () => cache.clear(),
27
+ };
28
+ }
@@ -0,0 +1,17 @@
1
+ export declare enum ResponseFormat {
2
+ MARKDOWN = "markdown",
3
+ JSON = "json"
4
+ }
5
+ export interface ToolTextResult {
6
+ content: {
7
+ type: "text";
8
+ text: string;
9
+ }[];
10
+ structuredContent?: Record<string, unknown>;
11
+ isError?: boolean;
12
+ [k: string]: unknown;
13
+ }
14
+ export declare function jsonBlock(data: unknown): string;
15
+ export declare function truncate(text: string, limit?: number): string;
16
+ export declare function toolResult(structured: Record<string, unknown>, text: string): ToolTextResult;
17
+ export declare function toolError(message: string, hint?: string, extra?: Record<string, unknown>): ToolTextResult;
@@ -0,0 +1,28 @@
1
+ import { CHARACTER_LIMIT } from "../constants.js";
2
+ export var ResponseFormat;
3
+ (function (ResponseFormat) {
4
+ ResponseFormat["MARKDOWN"] = "markdown";
5
+ ResponseFormat["JSON"] = "json";
6
+ })(ResponseFormat || (ResponseFormat = {}));
7
+ export function jsonBlock(data) {
8
+ return JSON.stringify(data, null, 2);
9
+ }
10
+ export function truncate(text, limit = CHARACTER_LIMIT) {
11
+ if (text.length <= limit)
12
+ return text;
13
+ return `${text.slice(0, limit)}\n\n…[truncated — response exceeded ${limit} characters. Use filters or pagination to narrow results.]`;
14
+ }
15
+ export function toolResult(structured, text) {
16
+ return {
17
+ content: [{ type: "text", text: truncate(text) }],
18
+ structuredContent: structured,
19
+ };
20
+ }
21
+ export function toolError(message, hint, extra) {
22
+ const payload = { error: true, message, hint, ...extra };
23
+ return {
24
+ content: [{ type: "text", text: jsonBlock(payload) }],
25
+ structuredContent: payload,
26
+ isError: true,
27
+ };
28
+ }
@@ -0,0 +1,90 @@
1
+ import type { Config } from "../config.js";
2
+ import type { Logger } from "../logger.js";
3
+ export interface GraphGetOptions {
4
+ /** Path segments after the API version, e.g. "me/businesses" or "act_123/insights". */
5
+ path: string;
6
+ /** Query-string params. `access_token` and `appsecret_proof` are added automatically. */
7
+ params?: Record<string, string | number | boolean | undefined>;
8
+ /** Skip cache for this request. */
9
+ noCache?: boolean;
10
+ /** Override the API version for this call. */
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;
22
+ /**
23
+ * Override the app secret used to compute `appsecret_proof` for this call.
24
+ * Required when `accessTokenOverride` is a token issued by a *different*
25
+ * Meta app (e.g. the secondary "insights app"), because the proof must be
26
+ * HMAC(token, that app's secret). Defaults to the primary app secret.
27
+ */
28
+ appSecretOverride?: string;
29
+ }
30
+ export interface GraphPagedResponse<T = unknown> {
31
+ data: T[];
32
+ paging?: {
33
+ cursors?: {
34
+ before?: string;
35
+ after?: string;
36
+ };
37
+ next?: string;
38
+ previous?: string;
39
+ };
40
+ /** Some endpoints (e.g. /me) return a scalar — preserved on the response object. */
41
+ [extra: string]: unknown;
42
+ }
43
+ export interface GraphRateLimitHeaders {
44
+ businessUseCase?: string;
45
+ adAccountUsage?: string;
46
+ appUsage?: string;
47
+ }
48
+ export declare class GraphClient {
49
+ private readonly config;
50
+ private readonly logger;
51
+ private readonly http;
52
+ private readonly cache;
53
+ private readonly pageTokens;
54
+ private lastRateLimit;
55
+ constructor(config: Config, logger: Logger);
56
+ get rateLimit(): GraphRateLimitHeaders;
57
+ clearCache(): void;
58
+ /**
59
+ * Resolve the Page access token for a Page id. Cached process-locally for
60
+ * the lifetime of the GraphClient (Page tokens don't expire as fast as
61
+ * user tokens, and this avoids paying the round-trip cost on every Page
62
+ * call). Page-level Graph edges (`/posts`, `/ratings`, `/videos`,
63
+ * `/insights`, `/{post_id}/insights`) require a Page access token rather
64
+ * than the configured system-user token; without it Meta returns
65
+ * `(#210) A page access token is required`.
66
+ *
67
+ * Requires the configured system user to be assigned to the Page with
68
+ * at least the "View performance" or "Analyze Page" task.
69
+ */
70
+ getPageAccessToken(pageId: string, creds?: {
71
+ token: string;
72
+ appSecret: string | undefined;
73
+ }): Promise<string>;
74
+ get<T = GraphPagedResponse>(opts: GraphGetOptions): Promise<T>;
75
+ /**
76
+ * Follow `paging.next` cursors and concatenate `data` arrays up to maxPages.
77
+ * Returns the combined data plus the final cursor so callers can resume.
78
+ */
79
+ getAllPages<TItem = unknown>(opts: GraphGetOptions, maxPages?: number): Promise<{
80
+ data: TItem[];
81
+ pages: number;
82
+ nextAfter: string | undefined;
83
+ }>;
84
+ private optsFromNextUrl;
85
+ private buildParams;
86
+ private appsecretProof;
87
+ private cacheKeyFor;
88
+ private requestWithRetry;
89
+ private captureRateLimit;
90
+ }
@@ -0,0 +1,219 @@
1
+ import axios from "axios";
2
+ import crypto from "node:crypto";
3
+ import { BASE_RETRY_DELAY_MS, GRAPH_BASE_URL, MAX_RETRIES, } from "../constants.js";
4
+ import { MetaError, ReadOnlyViolationError, normalizeAxiosError } from "../errors.js";
5
+ import { createCache } from "./cache.js";
6
+ export class GraphClient {
7
+ config;
8
+ logger;
9
+ http;
10
+ cache;
11
+ pageTokens = new Map();
12
+ lastRateLimit = {};
13
+ constructor(config, logger) {
14
+ this.config = config;
15
+ this.logger = logger;
16
+ this.http = axios.create({
17
+ baseURL: GRAPH_BASE_URL,
18
+ timeout: config.httpTimeoutMs,
19
+ headers: { Accept: "application/json" },
20
+ // Never follow redirects to non-GET — defence in depth even though we only GET.
21
+ maxRedirects: 3,
22
+ validateStatus: () => true, // handle status manually
23
+ });
24
+ this.cache = createCache(config.cacheTtlSeconds);
25
+ // Hard read-only guard — defence in depth. Catches misuse if new code ever
26
+ // calls http.post/patch/delete directly.
27
+ this.http.interceptors.request.use((cfg) => {
28
+ const method = (cfg.method ?? "get").toUpperCase();
29
+ if (method !== "GET") {
30
+ throw new ReadOnlyViolationError(method, cfg.url ?? "");
31
+ }
32
+ return cfg;
33
+ });
34
+ }
35
+ get rateLimit() {
36
+ return { ...this.lastRateLimit };
37
+ }
38
+ clearCache() {
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, creds) {
55
+ if (!pageId)
56
+ throw new MetaError("getPageAccessToken: pageId is required");
57
+ // Cache key namespaces by the issuing app so a Page token derived from the
58
+ // primary app and one derived from the secondary insights app never collide.
59
+ const cacheKey = creds ? `insights:${pageId}` : pageId;
60
+ const cached = this.pageTokens.get(cacheKey);
61
+ if (cached)
62
+ return cached;
63
+ const resp = await this.get({
64
+ path: pageId,
65
+ params: { fields: "access_token,id,name" },
66
+ // Don't cache via the URL+params LRU because the value is sensitive and
67
+ // we want to keep the per-page Map as the single source of truth.
68
+ noCache: true,
69
+ accessTokenOverride: creds?.token,
70
+ appSecretOverride: creds?.appSecret,
71
+ });
72
+ if (!resp.access_token) {
73
+ 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.`, {
74
+ hint: "In Business Settings → System Users → AI_Insights_Reader → Add Assets, assign this Page with at least 'View performance' or 'Analyze Page' tasks.",
75
+ });
76
+ }
77
+ this.pageTokens.set(cacheKey, resp.access_token);
78
+ return resp.access_token;
79
+ }
80
+ async get(opts) {
81
+ const version = opts.apiVersion ?? this.config.apiVersion;
82
+ const path = opts.path.replace(/^\/+/, "");
83
+ const url = `/${version}/${path}`;
84
+ const params = this.buildParams(opts.params, opts.accessTokenOverride, opts.appSecretOverride);
85
+ const cacheKey = this.cacheKeyFor(url, params);
86
+ if (!opts.noCache) {
87
+ const cached = this.cache.get(cacheKey);
88
+ if (cached)
89
+ return cached;
90
+ }
91
+ const result = await this.requestWithRetry({ method: "GET", url, params });
92
+ if (!opts.noCache) {
93
+ this.cache.set(cacheKey, result);
94
+ }
95
+ return result;
96
+ }
97
+ /**
98
+ * Follow `paging.next` cursors and concatenate `data` arrays up to maxPages.
99
+ * Returns the combined data plus the final cursor so callers can resume.
100
+ */
101
+ async getAllPages(opts, maxPages) {
102
+ const cap = maxPages ?? this.config.maxAutoPages;
103
+ const collected = [];
104
+ let pages = 0;
105
+ let nextAfter;
106
+ let currentOpts = opts;
107
+ while (currentOpts && pages < cap) {
108
+ const page = await this.get(currentOpts);
109
+ pages += 1;
110
+ if (Array.isArray(page.data))
111
+ collected.push(...page.data);
112
+ nextAfter = page.paging?.cursors?.after;
113
+ if (page.paging?.next && pages < cap) {
114
+ // Propagate the original token + app-secret overrides to subsequent
115
+ // pages — optsFromNextUrl strips credentials from the URL but the
116
+ // overrides (if any) must continue to be applied.
117
+ const next = this.optsFromNextUrl(page.paging.next);
118
+ next.accessTokenOverride = opts.accessTokenOverride;
119
+ next.appSecretOverride = opts.appSecretOverride;
120
+ currentOpts = next;
121
+ }
122
+ else {
123
+ currentOpts = null;
124
+ }
125
+ }
126
+ return { data: collected, pages, nextAfter };
127
+ }
128
+ optsFromNextUrl(nextUrl) {
129
+ const u = new URL(nextUrl);
130
+ const segments = u.pathname.replace(/^\/+/, "").split("/");
131
+ const version = segments.shift() ?? this.config.apiVersion;
132
+ const path = segments.join("/");
133
+ const params = {};
134
+ for (const [k, v] of u.searchParams.entries()) {
135
+ if (k === "access_token" || k === "appsecret_proof")
136
+ continue;
137
+ params[k] = v;
138
+ }
139
+ return { path, params, apiVersion: version };
140
+ }
141
+ buildParams(params, accessTokenOverride, appSecretOverride) {
142
+ const out = {};
143
+ for (const [k, v] of Object.entries(params ?? {})) {
144
+ if (v === undefined || v === null)
145
+ continue;
146
+ out[k] = v;
147
+ }
148
+ const token = accessTokenOverride ?? this.config.accessToken;
149
+ // The app secret must match the app that ISSUED the token. For a
150
+ // secondary-app token, the caller passes appSecretOverride; otherwise we
151
+ // fall back to the primary app secret.
152
+ const appSecret = appSecretOverride ?? this.config.appSecret;
153
+ out.access_token = token;
154
+ if (appSecret) {
155
+ out.appsecret_proof = this.appsecretProof(token, appSecret);
156
+ }
157
+ return out;
158
+ }
159
+ appsecretProof(token, appSecret) {
160
+ // appsecret_proof = HMAC-SHA256(access_token, app_secret).
161
+ // Recompute against whichever (token, app-secret) pair is in use —
162
+ // primary system-user token, Page token, or a secondary-app token.
163
+ return crypto.createHmac("sha256", appSecret).update(token).digest("hex");
164
+ }
165
+ cacheKeyFor(url, params) {
166
+ const { access_token: _at, appsecret_proof: _ap, ...rest } = params;
167
+ const ordered = Object.keys(rest)
168
+ .sort()
169
+ .map((k) => `${k}=${rest[k]}`)
170
+ .join("&");
171
+ return `${url}?${ordered}`;
172
+ }
173
+ async requestWithRetry(cfg) {
174
+ let lastError = null;
175
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
176
+ try {
177
+ const response = await this.http.request(cfg);
178
+ this.captureRateLimit(response.headers);
179
+ if (response.status >= 200 && response.status < 300) {
180
+ return response.data;
181
+ }
182
+ // Non-2xx: construct as if axios threw.
183
+ lastError = normalizeAxiosError({
184
+ isAxiosError: true,
185
+ response,
186
+ message: `HTTP ${response.status}`,
187
+ config: cfg,
188
+ });
189
+ }
190
+ catch (err) {
191
+ if (err instanceof ReadOnlyViolationError)
192
+ throw err;
193
+ lastError = normalizeAxiosError(err);
194
+ }
195
+ if (!lastError.retryable || attempt === MAX_RETRIES - 1) {
196
+ throw lastError;
197
+ }
198
+ const delay = BASE_RETRY_DELAY_MS * Math.pow(2, attempt);
199
+ this.logger.warn({ code: lastError.code, attempt, delay_ms: delay }, "meta api retrying after error");
200
+ await sleep(delay);
201
+ }
202
+ // Unreachable, but TypeScript needs it.
203
+ throw lastError ?? new MetaError("Unknown request failure");
204
+ }
205
+ captureRateLimit(headers) {
206
+ const get = (k) => {
207
+ const v = headers[k] ?? headers[k.toLowerCase()];
208
+ return typeof v === "string" ? v : undefined;
209
+ };
210
+ this.lastRateLimit = {
211
+ businessUseCase: get("x-business-use-case-usage"),
212
+ adAccountUsage: get("x-ad-account-usage"),
213
+ appUsage: get("x-app-usage"),
214
+ };
215
+ }
216
+ }
217
+ function sleep(ms) {
218
+ return new Promise((resolve) => setTimeout(resolve, ms));
219
+ }
@@ -0,0 +1,30 @@
1
+ import { z } from "zod";
2
+ import { ResponseFormat } from "./format.js";
3
+ /**
4
+ * Canonical pagination input shared across list-style tools.
5
+ * - `limit`: items per page (capped)
6
+ * - `after`: opaque Graph cursor to resume from
7
+ * - `auto_paginate`: if true, follow `paging.next` up to max_pages
8
+ */
9
+ export declare const paginationShape: {
10
+ limit: z.ZodDefault<z.ZodNumber>;
11
+ after: z.ZodOptional<z.ZodString>;
12
+ auto_paginate: z.ZodDefault<z.ZodBoolean>;
13
+ };
14
+ export declare const responseFormatShape: {
15
+ response_format: z.ZodDefault<z.ZodNativeEnum<typeof ResponseFormat>>;
16
+ };
17
+ /** Meta's expected ID shapes. Loose on purpose — Graph accepts numeric or prefixed forms. */
18
+ export declare const metaIdSchema: z.ZodString;
19
+ export declare const adAccountIdSchema: z.ZodString;
20
+ export declare const datePresetSchema: z.ZodEnum<["today", "yesterday", "this_month", "last_month", "this_quarter", "maximum", "last_3d", "last_7d", "last_14d", "last_28d", "last_30d", "last_90d", "last_week_mon_sun", "last_week_sun_sat", "last_quarter", "last_year", "this_week_mon_today", "this_week_sun_today", "this_year"]>;
21
+ export declare const timeRangeSchema: z.ZodObject<{
22
+ since: z.ZodString;
23
+ until: z.ZodString;
24
+ }, "strip", z.ZodTypeAny, {
25
+ since: string;
26
+ until: string;
27
+ }, {
28
+ since: string;
29
+ until: string;
30
+ }>;
@@ -0,0 +1,69 @@
1
+ import { z } from "zod";
2
+ import { ResponseFormat } from "./format.js";
3
+ /**
4
+ * Canonical pagination input shared across list-style tools.
5
+ * - `limit`: items per page (capped)
6
+ * - `after`: opaque Graph cursor to resume from
7
+ * - `auto_paginate`: if true, follow `paging.next` up to max_pages
8
+ */
9
+ export const paginationShape = {
10
+ limit: z
11
+ .number()
12
+ .int()
13
+ .min(1)
14
+ .max(500)
15
+ .default(25)
16
+ .describe("Items per page (1-500). Meta may cap lower for some endpoints."),
17
+ after: z
18
+ .string()
19
+ .optional()
20
+ .describe("Opaque Graph `paging.cursors.after` value from a previous response."),
21
+ auto_paginate: z
22
+ .boolean()
23
+ .default(false)
24
+ .describe("If true, follow `paging.next` up to META_MAX_AUTO_PAGES and return the concatenated data."),
25
+ };
26
+ export const responseFormatShape = {
27
+ response_format: z
28
+ .nativeEnum(ResponseFormat)
29
+ .default(ResponseFormat.JSON)
30
+ .describe("Output format: 'json' for machine-readable, 'markdown' for human-readable."),
31
+ };
32
+ /** Meta's expected ID shapes. Loose on purpose — Graph accepts numeric or prefixed forms. */
33
+ export const metaIdSchema = z
34
+ .string()
35
+ .min(1)
36
+ .describe("Meta object ID. Numeric ID or prefixed form (e.g. ad accounts use 'act_<digits>').");
37
+ export const adAccountIdSchema = z
38
+ .string()
39
+ .regex(/^act_\d+$/, "Ad account ID must start with 'act_' (e.g. 'act_1234567890').")
40
+ .describe("Ad account ID in the 'act_<digits>' form.");
41
+ export const datePresetSchema = z
42
+ .enum([
43
+ "today",
44
+ "yesterday",
45
+ "this_month",
46
+ "last_month",
47
+ "this_quarter",
48
+ "maximum",
49
+ "last_3d",
50
+ "last_7d",
51
+ "last_14d",
52
+ "last_28d",
53
+ "last_30d",
54
+ "last_90d",
55
+ "last_week_mon_sun",
56
+ "last_week_sun_sat",
57
+ "last_quarter",
58
+ "last_year",
59
+ "this_week_mon_today",
60
+ "this_week_sun_today",
61
+ "this_year",
62
+ ])
63
+ .describe("Meta date preset. See Marketing API insights docs for the full set.");
64
+ export const timeRangeSchema = z
65
+ .object({
66
+ since: z.string().describe("ISO date, e.g. '2026-01-01'."),
67
+ until: z.string().describe("ISO date, e.g. '2026-01-31'."),
68
+ })
69
+ .describe("Explicit date range. Overrides date_preset when both are supplied.");
@@ -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,125 @@
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
+ * Per-field recovery hints: when the AI passes a placeholder for a specific
47
+ * field, tell it the *exact* discovery tool that produces the right ID. This
48
+ * turns "I need your IG account ID" (which the AI then asks the user) into
49
+ * "call meta_ig_list_accounts (no args) to discover IG accounts" (which the
50
+ * AI can do autonomously).
51
+ *
52
+ * Each entry returns a single-sentence next-step the AI should execute.
53
+ */
54
+ const FIELD_RECOVERY = {
55
+ business_id: "Call `meta_business_list` (no args) to discover Business Manager IDs. The configured business is `133767790806312` (Hodusoft) — use that directly if you already know.",
56
+ ad_account_id: "Call `meta_ads_list_accounts business_id=133767790806312` to discover ad accounts. Returns IDs in the `act_<digits>` form (e.g. `act_146517954996436`).",
57
+ page_id: "Call `meta_page_list` (no args, defaults to source='assigned') to discover Pages assigned to the configured token.",
58
+ ig_user_id: "Call `meta_ig_list_accounts` (no args) to discover Instagram Business accounts linked to your Pages. No parameters required.",
59
+ pixel_id: "Call `meta_pixel_list business_id=133767790806312` to discover pixels.",
60
+ catalog_id: "Call `meta_catalog_list business_id=133767790806312 scope='owned'` to discover catalogs.",
61
+ waba_id: "Call `meta_whatsapp_list_wabas business_id=133767790806312` to discover WhatsApp Business Accounts.",
62
+ media_id: "Call `meta_ig_list_media ig_user_id=<ig_id>` to discover media. Use `meta_ig_list_accounts` first if you don't have ig_id.",
63
+ creative_id: "Call `meta_ads_list_ads ad_account_id=act_<id>` to discover creatives. Each ad's `creative.id` is the value you want.",
64
+ campaign_id: "Call `meta_ads_list_campaigns ad_account_id=act_<id>` to discover campaign IDs.",
65
+ adset_id: "Call `meta_ads_list_adsets ad_account_id=act_<id>` to discover ad-set IDs.",
66
+ post_id: "Call `meta_page_list_posts page_id=<page_id>` to discover post IDs. Post IDs use the `{page_id}_{post_id}` format.",
67
+ object_id: "Pass an ad account (`act_<digits>`), campaign, ad-set, or ad ID. Use `meta_business_overview business_id=133767790806312` to discover all of these in one call.",
68
+ };
69
+ /** Build a numbered recovery action list keyed off the placeholder field names. */
70
+ function buildRecoveryActions(placeholders) {
71
+ const fields = new Set();
72
+ for (const p of placeholders) {
73
+ const top = p.path[0];
74
+ if (typeof top === "string")
75
+ fields.add(top);
76
+ }
77
+ const lines = [];
78
+ for (const f of fields) {
79
+ const recovery = FIELD_RECOVERY[f];
80
+ if (recovery)
81
+ lines.push(`• ${f}: ${recovery}`);
82
+ }
83
+ if (lines.length === 0) {
84
+ return "Replace the placeholder values with real IDs. If you don't know the IDs, call `meta_business_overview business_id=133767790806312` first to discover everything in one call.";
85
+ }
86
+ return `DO NOT ask the user for these IDs — the server can discover them for you. Replace each placeholder by first calling the corresponding discovery tool:\n${lines.join("\n")}\n\nShortcut: a single call to \`meta_business_overview business_id=133767790806312\` returns all Pages, IG accounts, ad accounts, pixels, catalogs, and WABAs at once. Prefer that when several placeholders appear together.`;
87
+ }
88
+ /**
89
+ * Defence-in-depth input validator. Wraps a Zod object schema and:
90
+ * 1. Detects placeholder strings (e.g. literal "<YOUR_PAGE_ID>") with a
91
+ * friendly tool error explaining the AI must substitute real values.
92
+ * 2. Runs full Zod validation including refinements (regex, min/max, etc.).
93
+ * 3. Returns a structured ToolError on failure rather than throwing,
94
+ * so the AI gets actionable feedback instead of an opaque crash.
95
+ *
96
+ * Why this exists: some MCP gateways forward inputs to handlers without
97
+ * enforcing per-property Zod refinements. Without this guard, a placeholder
98
+ * like "act_<YOUR_PAGE_ID>" would reach Meta's API and waste a round trip.
99
+ */
100
+ export function validateInput(schema, args) {
101
+ const placeholders = findPlaceholders(args);
102
+ if (placeholders.length > 0) {
103
+ const summary = placeholders
104
+ .map((p) => `${formatPath(p.path)} = ${JSON.stringify(p.value)}`)
105
+ .join("; ");
106
+ return {
107
+ ok: false,
108
+ error: toolError(`Input contains placeholder values that were not substituted: ${summary}`, buildRecoveryActions(placeholders), { placeholders, recovery_actions: buildRecoveryActions(placeholders) }),
109
+ };
110
+ }
111
+ const parsed = schema.safeParse(args);
112
+ if (!parsed.success) {
113
+ const issues = parsed.error.issues.map((iss) => ({
114
+ path: formatPath(iss.path),
115
+ message: iss.message,
116
+ code: iss.code,
117
+ }));
118
+ const summary = issues.map((i) => `${i.path}: ${i.message}`).join("; ");
119
+ return {
120
+ ok: false,
121
+ 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 }),
122
+ };
123
+ }
124
+ return { ok: true, data: parsed.data };
125
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { loadConfig } from "./config.js";
4
+ import { createLogger } from "./logger.js";
5
+ import { buildServer } from "./server.js";
6
+ async function main() {
7
+ let config;
8
+ try {
9
+ config = loadConfig();
10
+ }
11
+ catch (err) {
12
+ process.stderr.write(`[meta-mcp] configuration error: ${err.message}\n`);
13
+ process.exit(1);
14
+ }
15
+ const logger = createLogger(config.logLevel);
16
+ logger.info({ api_version: config.apiVersion, appsecret_proof: Boolean(config.appSecret) }, "starting meta-business-manager-mcp-server");
17
+ const { server } = buildServer(config, logger);
18
+ const transport = new StdioServerTransport();
19
+ await server.connect(transport);
20
+ logger.info("stdio transport connected");
21
+ const shutdown = async (signal) => {
22
+ logger.info({ signal }, "shutting down");
23
+ try {
24
+ await server.close();
25
+ }
26
+ finally {
27
+ process.exit(0);
28
+ }
29
+ };
30
+ process.on("SIGINT", () => void shutdown("SIGINT"));
31
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
32
+ }
33
+ main().catch((err) => {
34
+ process.stderr.write(`[meta-mcp] fatal: ${err.stack ?? err}\n`);
35
+ process.exit(1);
36
+ });