facebook-mcp-server 1.6.6

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 (64) hide show
  1. package/.env.example +2 -0
  2. package/.github/dependabot.yml +50 -0
  3. package/.github/workflows/ci.yml +51 -0
  4. package/.github/workflows/release.yml +200 -0
  5. package/CONTRIBUTING.md +112 -0
  6. package/LICENSE +21 -0
  7. package/README.md +128 -0
  8. package/dist/client.d.ts +57 -0
  9. package/dist/client.js +140 -0
  10. package/dist/client.test.d.ts +9 -0
  11. package/dist/client.test.js +211 -0
  12. package/dist/create-post.d.ts +39 -0
  13. package/dist/create-post.js +85 -0
  14. package/dist/create-post.test.d.ts +11 -0
  15. package/dist/create-post.test.js +175 -0
  16. package/dist/errors.d.ts +12 -0
  17. package/dist/errors.js +87 -0
  18. package/dist/errors.test.d.ts +9 -0
  19. package/dist/errors.test.js +162 -0
  20. package/dist/first-comment.test.d.ts +10 -0
  21. package/dist/first-comment.test.js +54 -0
  22. package/dist/handlers.test.d.ts +19 -0
  23. package/dist/handlers.test.js +333 -0
  24. package/dist/index.d.ts +44 -0
  25. package/dist/index.js +374 -0
  26. package/dist/lib/index.d.ts +9 -0
  27. package/dist/lib/index.js +8 -0
  28. package/dist/lib/insights.d.ts +53 -0
  29. package/dist/lib/insights.js +47 -0
  30. package/dist/rate-limiter.d.ts +71 -0
  31. package/dist/rate-limiter.js +214 -0
  32. package/dist/rate-limiter.test.d.ts +1 -0
  33. package/dist/rate-limiter.test.js +154 -0
  34. package/dist/response.d.ts +24 -0
  35. package/dist/response.js +35 -0
  36. package/dist/response.test.d.ts +1 -0
  37. package/dist/response.test.js +71 -0
  38. package/dist/sanitize.d.ts +17 -0
  39. package/dist/sanitize.js +27 -0
  40. package/dist/sanitize.test.d.ts +1 -0
  41. package/dist/sanitize.test.js +43 -0
  42. package/dist/tools.test.d.ts +16 -0
  43. package/dist/tools.test.js +150 -0
  44. package/package.json +29 -0
  45. package/src/client.test.ts +284 -0
  46. package/src/client.ts +204 -0
  47. package/src/create-post.test.ts +196 -0
  48. package/src/create-post.ts +118 -0
  49. package/src/errors.test.ts +297 -0
  50. package/src/errors.ts +108 -0
  51. package/src/first-comment.test.ts +73 -0
  52. package/src/handlers.test.ts +431 -0
  53. package/src/index.ts +540 -0
  54. package/src/lib/index.ts +9 -0
  55. package/src/lib/insights.ts +150 -0
  56. package/src/rate-limiter.test.ts +186 -0
  57. package/src/rate-limiter.ts +252 -0
  58. package/src/response.test.ts +80 -0
  59. package/src/response.ts +43 -0
  60. package/src/sanitize.test.ts +52 -0
  61. package/src/sanitize.ts +35 -0
  62. package/src/tools.test.ts +195 -0
  63. package/tsconfig.json +15 -0
  64. package/vitest.config.ts +10 -0
package/dist/index.js ADDED
@@ -0,0 +1,374 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Standalone Facebook Pages MCP Server
4
+ *
5
+ * Dual-purpose SENSE + ACT server for the Facebook Graph API (Pages).
6
+ * Facebook publishes are synchronous — one POST to /{page-id}/feed
7
+ * (or /photos, /videos) returns the new post ID directly.
8
+ *
9
+ * Tools:
10
+ * SENSE: fb_get_page_insights, fb_get_post_insights, fb_get_comments,
11
+ * fb_get_page_feed
12
+ * ACT: fb_create_post, fb_reply_comment, fb_delete_post
13
+ */
14
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
15
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
16
+ import { z } from "zod";
17
+ import { textResult, errorResult, senseResult } from "./response.js";
18
+ import { createClient, FacebookApiError, } from "./client.js";
19
+ import { waitForRateLimit, withRetry } from "./rate-limiter.js";
20
+ import { sanitize } from "./sanitize.js";
21
+ import { extractApiDetail, suggestAction } from "./errors.js";
22
+ import { buildCreatePostRequest } from "./create-post.js";
23
+ import { fetchPageInsights, fetchPostInsights, fetchPageFeed, } from "./lib/insights.js";
24
+ import { createRequire } from "node:module";
25
+ const require = createRequire(import.meta.url);
26
+ const { version } = require("../package.json");
27
+ // --- Env-based defaults ---
28
+ const DEFAULT_ACCESS_TOKEN = process.env.FACEBOOK_ACCESS_TOKEN;
29
+ const DEFAULT_PAGE_ID = process.env.FACEBOOK_PAGE_ID;
30
+ // --- Credential resolution ---
31
+ const credentialFields = {
32
+ accessToken: z
33
+ .string()
34
+ .optional()
35
+ .describe("Facebook Page access token. Falls back to FACEBOOK_ACCESS_TOKEN env var."),
36
+ pageId: z
37
+ .string()
38
+ .optional()
39
+ .describe("Facebook Page ID (the numeric id of the page you admin). Falls back to FACEBOOK_PAGE_ID env var."),
40
+ };
41
+ export function resolveCredentials(args) {
42
+ const accessToken = args.accessToken || DEFAULT_ACCESS_TOKEN;
43
+ const pageId = args.pageId || DEFAULT_PAGE_ID;
44
+ if (!accessToken || !pageId)
45
+ return null;
46
+ return { accessToken, pageId };
47
+ }
48
+ export async function getClient(args, toolName) {
49
+ const creds = resolveCredentials(args);
50
+ if (!creds) {
51
+ return {
52
+ ok: false,
53
+ error: errorResult("Missing credentials", "Provide accessToken + pageId as arguments, or set FACEBOOK_ACCESS_TOKEN and FACEBOOK_PAGE_ID env vars."),
54
+ };
55
+ }
56
+ // Pre-flight rate limit check (per-tenant, keyed by pageId)
57
+ const limit = await waitForRateLimit(toolName, creds.pageId);
58
+ if (!limit.allowed) {
59
+ const retryAfterSeconds = Math.ceil(limit.retryAfterMs / 1000);
60
+ return {
61
+ ok: false,
62
+ error: errorResult("Rate limited", `Facebook API rate limit reached. Wait ${retryAfterSeconds}s then retry.`, {
63
+ retryAfterSeconds,
64
+ action: retryAfterSeconds <= 120
65
+ ? `RETRY_AFTER_WAIT: Sleep ${retryAfterSeconds}s then retry this tool call.`
66
+ : `DEFER: Rate limit cooldown is ${retryAfterSeconds}s. Switch to a different task.`,
67
+ }),
68
+ };
69
+ }
70
+ try {
71
+ const client = createClient(creds);
72
+ return { ok: true, client };
73
+ }
74
+ catch (e) {
75
+ const msg = e instanceof Error ? e.message : "Unknown error";
76
+ return {
77
+ ok: false,
78
+ error: errorResult("Client error", `Failed to create Facebook client: ${msg}`),
79
+ };
80
+ }
81
+ }
82
+ // --- Error handling ---
83
+ export function safeHandler(toolName, handler) {
84
+ return async (args) => {
85
+ try {
86
+ return await handler(args);
87
+ }
88
+ catch (e) {
89
+ try {
90
+ const msg = e instanceof Error ? e.message : String(e);
91
+ const detail = extractApiDetail(e);
92
+ const statusCode = e instanceof FacebookApiError ? e.status : undefined;
93
+ const action = suggestAction(toolName, statusCode, detail, msg);
94
+ console.error(`[${toolName}] Error: ${msg}${detail ? ` — ${detail}` : ""}`);
95
+ return errorResult("API error", `${toolName} failed: ${detail || msg}`, {
96
+ ...(statusCode !== undefined && { statusCode }),
97
+ ...(detail && detail !== msg && { rawError: msg }),
98
+ ...(action && { action }),
99
+ });
100
+ }
101
+ catch (formatErr) {
102
+ const fallback = e instanceof Error ? e.message : "Unknown error";
103
+ const fmtMsg = formatErr instanceof Error ? formatErr.message : String(formatErr);
104
+ console.error(`[${toolName}] Error (fallback): ${fallback} — error formatting also failed: ${fmtMsg}`);
105
+ return errorResult("API error", `${toolName} failed: ${fallback}`);
106
+ }
107
+ }
108
+ };
109
+ }
110
+ // --- Server Setup ---
111
+ const server = new McpServer({
112
+ name: "facebook-mcp-server",
113
+ version,
114
+ });
115
+ // =====================
116
+ // SENSE Tools (read)
117
+ // =====================
118
+ server.registerTool("fb_get_page_insights", {
119
+ description: "Get Facebook Page insights: impressions, reach, post engagements, and follower count over a period.",
120
+ inputSchema: {
121
+ ...credentialFields,
122
+ period: z
123
+ .enum(["day", "week", "days_28"])
124
+ .optional()
125
+ .describe('Aggregation period (default: "day")'),
126
+ since: z
127
+ .string()
128
+ .optional()
129
+ .describe("Start date as Unix timestamp (e.g., '1700000000')"),
130
+ until: z.string().optional().describe("End date as Unix timestamp"),
131
+ },
132
+ }, safeHandler("fb_get_page_insights", async (args) => {
133
+ const result = await getClient(args, "fb_get_page_insights");
134
+ if (!result.ok)
135
+ return result.error;
136
+ const { client } = result;
137
+ const data = await fetchPageInsights(client, {
138
+ period: args.period,
139
+ since: args.since,
140
+ until: args.until,
141
+ });
142
+ return senseResult(data, "Facebook");
143
+ }));
144
+ server.registerTool("fb_get_post_insights", {
145
+ description: "Get engagement metrics for a specific Facebook post: impressions, engaged users, clicks, and reactions.",
146
+ inputSchema: {
147
+ ...credentialFields,
148
+ postId: z
149
+ .string()
150
+ .describe("Facebook post ID (format: {page-id}_{post-id} or just post id)"),
151
+ },
152
+ }, safeHandler("fb_get_post_insights", async (args) => {
153
+ if (!args.postId.trim())
154
+ return errorResult("Invalid input", "postId cannot be empty");
155
+ const result = await getClient(args, "fb_get_post_insights");
156
+ if (!result.ok)
157
+ return result.error;
158
+ const { client } = result;
159
+ const data = await fetchPostInsights(client, { postId: args.postId });
160
+ return senseResult(data, "Facebook");
161
+ }));
162
+ server.registerTool("fb_get_comments", {
163
+ description: "Get comments on a Facebook post. Returns comment text, author name, and timestamps. Content is sanitized for safe agent consumption.",
164
+ inputSchema: {
165
+ ...credentialFields,
166
+ postId: z.string().describe("Facebook post ID"),
167
+ limit: z
168
+ .number()
169
+ .optional()
170
+ .describe("Number of comments (default: 25, max: 100)"),
171
+ after: z.string().optional().describe("Pagination cursor"),
172
+ },
173
+ }, safeHandler("fb_get_comments", async (args) => {
174
+ if (!args.postId.trim())
175
+ return errorResult("Invalid input", "postId cannot be empty");
176
+ const result = await getClient(args, "fb_get_comments");
177
+ if (!result.ok)
178
+ return result.error;
179
+ const { client } = result;
180
+ const params = {
181
+ fields: "id,message,from{id,name},created_time,like_count,comment_count",
182
+ limit: String(Math.min(args.limit || 25, 100)),
183
+ };
184
+ if (args.after)
185
+ params.after = args.after;
186
+ const response = await withRetry(() => client.get(`/${args.postId}/comments`, params));
187
+ // Sanitize user-generated content
188
+ const comments = (response.data || []).map((c) => ({
189
+ id: c.id,
190
+ message: sanitize(c.message || ""),
191
+ author: c.from
192
+ ? { id: c.from.id, name: sanitize(c.from.name) }
193
+ : undefined,
194
+ createdTime: c.created_time,
195
+ likeCount: c.like_count ?? 0,
196
+ replyCount: c.comment_count ?? 0,
197
+ }));
198
+ return senseResult({
199
+ postId: args.postId,
200
+ comments,
201
+ count: comments.length,
202
+ nextCursor: response.paging?.cursors?.after,
203
+ }, "Facebook");
204
+ }));
205
+ server.registerTool("fb_get_page_feed", {
206
+ description: "Get the Facebook Page's own feed (posts you've published). Returns post id, message, type, timestamp, shares, and permalink.",
207
+ inputSchema: {
208
+ ...credentialFields,
209
+ limit: z
210
+ .number()
211
+ .optional()
212
+ .describe("Number of posts to fetch (default: 25, max: 100)"),
213
+ after: z.string().optional().describe("Pagination cursor"),
214
+ },
215
+ }, safeHandler("fb_get_page_feed", async (args) => {
216
+ const result = await getClient(args, "fb_get_page_feed");
217
+ if (!result.ok)
218
+ return result.error;
219
+ const { client } = result;
220
+ const data = await fetchPageFeed(client, {
221
+ limit: args.limit,
222
+ after: args.after,
223
+ });
224
+ return senseResult(data, "Facebook");
225
+ }));
226
+ // =====================
227
+ // ACT Tools (write)
228
+ // =====================
229
+ /**
230
+ * Optionally post a top-level comment on a just-created Facebook post (#1995).
231
+ * Returns { id } on success, { error } on failure, {} if no firstComment given.
232
+ * Mirrors the LinkedIn first-comment contract: 3-5s random delay, 2000 char cap.
233
+ */
234
+ export async function postFirstComment(client, postId, firstComment) {
235
+ const trimmed = firstComment?.trim();
236
+ if (!trimmed)
237
+ return {};
238
+ const commentText = trimmed.slice(0, 2000);
239
+ try {
240
+ const delayMs = 3000 + Math.random() * 2000;
241
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
242
+ const commentResponse = await withRetry(() => client.post(`/${postId}/comments`, {
243
+ message: commentText,
244
+ }));
245
+ return { id: commentResponse.id };
246
+ }
247
+ catch (e) {
248
+ const errMsg = e instanceof Error ? e.message : String(e);
249
+ console.error(`[fb_create_post] First comment failed: ${errMsg}`);
250
+ return { error: errMsg };
251
+ }
252
+ }
253
+ server.registerTool("fb_create_post", {
254
+ description: "Create a Facebook Page post. Supports text, link, photo, or video. Photos and videos must have publicly accessible HTTPS URLs. Returns the new post ID. Publishing is synchronous — no container flow.",
255
+ inputSchema: {
256
+ ...credentialFields,
257
+ message: z
258
+ .string()
259
+ .optional()
260
+ .describe("Post text. Required for text-only posts, optional for photo/video/link."),
261
+ link: z
262
+ .string()
263
+ .optional()
264
+ .describe("URL to share as a link preview. Mutually exclusive with imageUrl/videoUrl."),
265
+ imageUrl: z
266
+ .string()
267
+ .optional()
268
+ .describe("Publicly accessible image URL for a photo post. Mutually exclusive with link/videoUrl."),
269
+ videoUrl: z
270
+ .string()
271
+ .optional()
272
+ .describe("Publicly accessible video URL for a video post. Mutually exclusive with link/imageUrl."),
273
+ published: z
274
+ .boolean()
275
+ .optional()
276
+ .describe("Whether to publish immediately (default: true). Set false to create an unpublished draft."),
277
+ firstComment: z
278
+ .string()
279
+ .optional()
280
+ .describe("Optional follow-up comment posted ~a few seconds after the post for engagement (max 2000 chars). " +
281
+ "Facebook suppresses link-in-body reach so first-comment is the canonical place for a link/CTA. " +
282
+ "The comment is posted by the authenticated Page. Omit or leave blank to skip."),
283
+ },
284
+ }, safeHandler("fb_create_post", async (args) => {
285
+ const validation = buildCreatePostRequest(args);
286
+ if (!validation.ok) {
287
+ return errorResult(validation.error, validation.message, {
288
+ ...(validation.action && { action: validation.action }),
289
+ });
290
+ }
291
+ const result = await getClient(args, "fb_create_post");
292
+ if (!result.ok)
293
+ return result.error;
294
+ const { client } = result;
295
+ const endpoint = validation.endpoint(client.pageId);
296
+ const response = await withRetry(() => client.post(endpoint, validation.body));
297
+ // Photos/videos return { id, post_id } where post_id is the feed-level id
298
+ const postId = response.post_id || response.id;
299
+ // First comment (if provided). Partial-success: post is already live, so a
300
+ // comment failure must surface explicitly — never a clean success (#1931).
301
+ const fc = await postFirstComment(client, postId, args.firstComment);
302
+ if (fc.error) {
303
+ return errorResult("Partial failure", `Post created (${postId}) but first comment failed: ${fc.error}. Use fb_reply_comment to retry.`, { id: postId, mediaId: response.id, type: validation.type });
304
+ }
305
+ return textResult({
306
+ id: postId,
307
+ mediaId: response.id,
308
+ type: validation.type,
309
+ ...(fc.id && { firstCommentId: fc.id }),
310
+ message: fc.id
311
+ ? "Post created with first comment"
312
+ : "Post created successfully",
313
+ });
314
+ }));
315
+ server.registerTool("fb_reply_comment", {
316
+ description: "Reply to a comment on a Facebook post.",
317
+ inputSchema: {
318
+ ...credentialFields,
319
+ commentId: z.string().describe("ID of the comment to reply to"),
320
+ message: z.string().describe("Reply text"),
321
+ },
322
+ }, safeHandler("fb_reply_comment", async (args) => {
323
+ if (!args.commentId.trim())
324
+ return errorResult("Invalid input", "commentId cannot be empty");
325
+ if (!args.message.trim())
326
+ return errorResult("Invalid input", "message cannot be empty");
327
+ const result = await getClient(args, "fb_reply_comment");
328
+ if (!result.ok)
329
+ return result.error;
330
+ const { client } = result;
331
+ const response = await withRetry(() => client.post(`/${args.commentId}/comments`, {
332
+ message: args.message,
333
+ }));
334
+ return textResult({
335
+ id: response.id,
336
+ parentCommentId: args.commentId,
337
+ message: "Reply posted successfully",
338
+ });
339
+ }));
340
+ server.registerTool("fb_delete_post", {
341
+ description: "Delete a Facebook Page post you published. The post ID must belong to the authenticated Page.",
342
+ inputSchema: {
343
+ ...credentialFields,
344
+ postId: z.string().describe("ID of the post to delete"),
345
+ },
346
+ }, safeHandler("fb_delete_post", async (args) => {
347
+ if (!args.postId.trim())
348
+ return errorResult("Invalid input", "postId cannot be empty");
349
+ const result = await getClient(args, "fb_delete_post");
350
+ if (!result.ok)
351
+ return result.error;
352
+ const { client } = result;
353
+ await withRetry(() => client.delete(`/${args.postId}`));
354
+ return textResult({
355
+ postId: args.postId,
356
+ message: "Post deleted successfully",
357
+ });
358
+ }));
359
+ // --- Start ---
360
+ export { server };
361
+ async function main() {
362
+ const transport = new StdioServerTransport();
363
+ await server.connect(transport);
364
+ console.error("Facebook MCP Server running on stdio");
365
+ }
366
+ // Only start the stdio transport when invoked directly, not when imported
367
+ // by test files. Compares import.meta.url to the script entrypoint.
368
+ const isDirectRun = process.argv[1] && import.meta.url === `file://${process.argv[1]}`;
369
+ if (isDirectRun) {
370
+ main().catch((e) => {
371
+ console.error("Fatal:", e);
372
+ process.exit(1);
373
+ });
374
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Public surface for programmatic (non-MCP) consumers.
3
+ *
4
+ * Import from `facebook-mcp-server/lib` to use the insights functions and
5
+ * Graph API client directly without spinning up the MCP server.
6
+ */
7
+ export * from "./insights.js";
8
+ export { FacebookClient, FacebookApiError, createClient } from "../client.js";
9
+ export type { Credentials, GraphApiError } from "../client.js";
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Public surface for programmatic (non-MCP) consumers.
3
+ *
4
+ * Import from `facebook-mcp-server/lib` to use the insights functions and
5
+ * Graph API client directly without spinning up the MCP server.
6
+ */
7
+ export * from "./insights.js";
8
+ export { FacebookClient, FacebookApiError, createClient } from "../client.js";
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Pure SENSE/insights functions for the Facebook Graph API.
3
+ *
4
+ * Used by:
5
+ * 1. The MCP server's tool handlers (wrapped in `senseResult` for MCP protocol).
6
+ * 2. Any programmatic consumer importing from `facebook-mcp-server/lib`.
7
+ */
8
+ import type { FacebookClient } from "../client.js";
9
+ export interface PageInsightsArgs {
10
+ /** "day" | "week" | "days_28". Default: "day". */
11
+ period?: string;
12
+ /** Unix timestamp (string), inclusive. */
13
+ since?: string;
14
+ /** Unix timestamp (string), inclusive. */
15
+ until?: string;
16
+ }
17
+ export interface PageInsightsResult {
18
+ /** Period echoed back from the request. */
19
+ period: string;
20
+ /** Raw Graph API insights array — one element per metric. */
21
+ insights: unknown[];
22
+ }
23
+ export declare function fetchPageInsights(client: FacebookClient, args?: PageInsightsArgs): Promise<PageInsightsResult>;
24
+ export interface PostInsightsArgs {
25
+ /** Facebook post ID, e.g. `{page-id}_{post-id}` or just post id. */
26
+ postId: string;
27
+ }
28
+ export interface PostInsightsResult {
29
+ postId: string;
30
+ insights: unknown[];
31
+ }
32
+ export declare function fetchPostInsights(client: FacebookClient, args: PostInsightsArgs): Promise<PostInsightsResult>;
33
+ export interface PageFeedArgs {
34
+ /** Default 25, max 100. */
35
+ limit?: number;
36
+ /** Pagination cursor. */
37
+ after?: string;
38
+ }
39
+ export interface FeedPost {
40
+ id: string;
41
+ message: string;
42
+ createdTime: string;
43
+ permalinkUrl?: string;
44
+ shareCount: number;
45
+ statusType?: string;
46
+ story?: string;
47
+ }
48
+ export interface PageFeedResult {
49
+ posts: FeedPost[];
50
+ count: number;
51
+ nextCursor?: string;
52
+ }
53
+ export declare function fetchPageFeed(client: FacebookClient, args?: PageFeedArgs): Promise<PageFeedResult>;
@@ -0,0 +1,47 @@
1
+ import { withRetry } from "../rate-limiter.js";
2
+ import { sanitize } from "../sanitize.js";
3
+ const PAGE_INSIGHTS_METRICS = "page_impressions_unique,page_post_engagements,page_views_total,page_follows";
4
+ export async function fetchPageInsights(client, args = {}) {
5
+ const params = {
6
+ metric: PAGE_INSIGHTS_METRICS,
7
+ period: args.period || "day",
8
+ };
9
+ if (args.since)
10
+ params.since = args.since;
11
+ if (args.until)
12
+ params.until = args.until;
13
+ const response = await withRetry(() => client.get(`/${client.pageId}/insights`, params));
14
+ return { insights: response.data, period: params.period };
15
+ }
16
+ const POST_INSIGHTS_METRICS = "post_impressions_unique,post_clicks,post_reactions_by_type_total";
17
+ export async function fetchPostInsights(client, args) {
18
+ if (!args.postId.trim())
19
+ throw new Error("postId cannot be empty");
20
+ const response = await withRetry(() => client.get(`/${args.postId}/insights`, {
21
+ metric: POST_INSIGHTS_METRICS,
22
+ }));
23
+ return { postId: args.postId, insights: response.data };
24
+ }
25
+ export async function fetchPageFeed(client, args = {}) {
26
+ const params = {
27
+ fields: "id,message,created_time,permalink_url,shares,status_type,story",
28
+ limit: String(Math.min(args.limit || 25, 100)),
29
+ };
30
+ if (args.after)
31
+ params.after = args.after;
32
+ const response = await withRetry(() => client.get(`/${client.pageId}/feed`, params));
33
+ const posts = (response.data || []).map((p) => ({
34
+ id: p.id,
35
+ message: sanitize(p.message || ""),
36
+ createdTime: p.created_time,
37
+ permalinkUrl: p.permalink_url,
38
+ shareCount: p.shares?.count ?? 0,
39
+ statusType: p.status_type,
40
+ story: p.story ? sanitize(p.story) : undefined,
41
+ }));
42
+ return {
43
+ posts,
44
+ count: posts.length,
45
+ nextCursor: response.paging?.cursors?.after,
46
+ };
47
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Token-bucket rate limiter for Facebook Graph API.
3
+ *
4
+ * Facebook Graph API rate limits are per **Page**, not per app. This limiter
5
+ * keys buckets by the caller's `tenantKey` (the page id) so that one
6
+ * dashboard user posting aggressively cannot exhaust another user's quota.
7
+ *
8
+ * Each tenant gets an independent pair of buckets:
9
+ * - globalBucket: 200 tokens / 1 hour (all API calls)
10
+ * - publishBucket: 25 tokens / 24 hours (fb_create_post only)
11
+ *
12
+ * When `tenantKey` is undefined (single-tenant / env-based usage), all calls
13
+ * share a single default bucket pair under the sentinel key "__default__".
14
+ *
15
+ * Stale tenant entries are evicted after 4h of inactivity on every call to
16
+ * keep memory bounded for long-running multi-tenant servers.
17
+ *
18
+ * Also provides:
19
+ * - waitForRateLimit(): pre-flight check with optional wait
20
+ * - withRetry(): exponential backoff on server-side 429s
21
+ */
22
+ export declare class TokenBucket {
23
+ private tokens;
24
+ private lastRefill;
25
+ private readonly maxTokens;
26
+ private readonly refillRate;
27
+ constructor(config: {
28
+ maxTokens: number;
29
+ refillRate: number;
30
+ });
31
+ tryConsume(cost?: number): boolean;
32
+ msUntilAvailable(cost?: number): number;
33
+ private refill;
34
+ }
35
+ /**
36
+ * Test-only: wipe all tenant buckets. Used by unit tests to isolate cases.
37
+ */
38
+ export declare function __resetRateLimiter(): void;
39
+ export declare const PUBLISH_TOOL_NAMES: Set<string>;
40
+ /**
41
+ * Check rate limits and consume tokens.
42
+ * Peek-then-consume: check all relevant buckets before consuming any.
43
+ *
44
+ * @param toolName Tool being invoked (used to detect publish cost)
45
+ * @param tenantKey Per-tenant key (typically the Facebook Page id). If
46
+ * omitted, uses a shared default bucket — fine for
47
+ * single-tenant / env-based usage.
48
+ * @param overrideCost Cost to consume (default 1)
49
+ */
50
+ export declare function checkRateLimit(toolName?: string, tenantKey?: string, overrideCost?: number): {
51
+ allowed: true;
52
+ } | {
53
+ allowed: false;
54
+ retryAfterMs: number;
55
+ };
56
+ /**
57
+ * Pre-flight rate limit check. Waits up to 60s if bucket is near-empty.
58
+ * Returns DEFER guidance if wait would exceed 60s.
59
+ */
60
+ export declare function waitForRateLimit(toolName?: string, tenantKey?: string, overrideCost?: number): Promise<{
61
+ allowed: true;
62
+ } | {
63
+ allowed: false;
64
+ retryAfterMs: number;
65
+ }>;
66
+ /**
67
+ * Retry a function with exponential backoff on HTTP 429 errors.
68
+ * Also parses Retry-After header when available.
69
+ */
70
+ export declare function withRetry<T>(fn: () => Promise<T>): Promise<T>;
71
+ export declare function sleep(ms: number): Promise<void>;