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.
- package/.env.example +2 -0
- package/.github/dependabot.yml +50 -0
- package/.github/workflows/ci.yml +51 -0
- package/.github/workflows/release.yml +200 -0
- package/CONTRIBUTING.md +112 -0
- package/LICENSE +21 -0
- package/README.md +128 -0
- package/dist/client.d.ts +57 -0
- package/dist/client.js +140 -0
- package/dist/client.test.d.ts +9 -0
- package/dist/client.test.js +211 -0
- package/dist/create-post.d.ts +39 -0
- package/dist/create-post.js +85 -0
- package/dist/create-post.test.d.ts +11 -0
- package/dist/create-post.test.js +175 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +87 -0
- package/dist/errors.test.d.ts +9 -0
- package/dist/errors.test.js +162 -0
- package/dist/first-comment.test.d.ts +10 -0
- package/dist/first-comment.test.js +54 -0
- package/dist/handlers.test.d.ts +19 -0
- package/dist/handlers.test.js +333 -0
- package/dist/index.d.ts +44 -0
- package/dist/index.js +374 -0
- package/dist/lib/index.d.ts +9 -0
- package/dist/lib/index.js +8 -0
- package/dist/lib/insights.d.ts +53 -0
- package/dist/lib/insights.js +47 -0
- package/dist/rate-limiter.d.ts +71 -0
- package/dist/rate-limiter.js +214 -0
- package/dist/rate-limiter.test.d.ts +1 -0
- package/dist/rate-limiter.test.js +154 -0
- package/dist/response.d.ts +24 -0
- package/dist/response.js +35 -0
- package/dist/response.test.d.ts +1 -0
- package/dist/response.test.js +71 -0
- package/dist/sanitize.d.ts +17 -0
- package/dist/sanitize.js +27 -0
- package/dist/sanitize.test.d.ts +1 -0
- package/dist/sanitize.test.js +43 -0
- package/dist/tools.test.d.ts +16 -0
- package/dist/tools.test.js +150 -0
- package/package.json +29 -0
- package/src/client.test.ts +284 -0
- package/src/client.ts +204 -0
- package/src/create-post.test.ts +196 -0
- package/src/create-post.ts +118 -0
- package/src/errors.test.ts +297 -0
- package/src/errors.ts +108 -0
- package/src/first-comment.test.ts +73 -0
- package/src/handlers.test.ts +431 -0
- package/src/index.ts +540 -0
- package/src/lib/index.ts +9 -0
- package/src/lib/insights.ts +150 -0
- package/src/rate-limiter.test.ts +186 -0
- package/src/rate-limiter.ts +252 -0
- package/src/response.test.ts +80 -0
- package/src/response.ts +43 -0
- package/src/sanitize.test.ts +52 -0
- package/src/sanitize.ts +35 -0
- package/src/tools.test.ts +195 -0
- package/tsconfig.json +15 -0
- package/vitest.config.ts +10 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,540 @@
|
|
|
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
|
+
|
|
15
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
16
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
17
|
+
import { z } from "zod";
|
|
18
|
+
import { textResult, errorResult, senseResult } from "./response.js";
|
|
19
|
+
import {
|
|
20
|
+
createClient,
|
|
21
|
+
FacebookApiError,
|
|
22
|
+
type FacebookClient,
|
|
23
|
+
} from "./client.js";
|
|
24
|
+
import { waitForRateLimit, withRetry } from "./rate-limiter.js";
|
|
25
|
+
import { sanitize } from "./sanitize.js";
|
|
26
|
+
import { extractApiDetail, suggestAction } from "./errors.js";
|
|
27
|
+
import { buildCreatePostRequest } from "./create-post.js";
|
|
28
|
+
import {
|
|
29
|
+
fetchPageInsights,
|
|
30
|
+
fetchPostInsights,
|
|
31
|
+
fetchPageFeed,
|
|
32
|
+
} from "./lib/insights.js";
|
|
33
|
+
import { createRequire } from "node:module";
|
|
34
|
+
|
|
35
|
+
const require = createRequire(import.meta.url);
|
|
36
|
+
const { version } = require("../package.json") as { version: string };
|
|
37
|
+
|
|
38
|
+
// --- Env-based defaults ---
|
|
39
|
+
|
|
40
|
+
const DEFAULT_ACCESS_TOKEN = process.env.FACEBOOK_ACCESS_TOKEN;
|
|
41
|
+
const DEFAULT_PAGE_ID = process.env.FACEBOOK_PAGE_ID;
|
|
42
|
+
|
|
43
|
+
// --- Credential resolution ---
|
|
44
|
+
|
|
45
|
+
const credentialFields = {
|
|
46
|
+
accessToken: z
|
|
47
|
+
.string()
|
|
48
|
+
.optional()
|
|
49
|
+
.describe(
|
|
50
|
+
"Facebook Page access token. Falls back to FACEBOOK_ACCESS_TOKEN env var.",
|
|
51
|
+
),
|
|
52
|
+
pageId: z
|
|
53
|
+
.string()
|
|
54
|
+
.optional()
|
|
55
|
+
.describe(
|
|
56
|
+
"Facebook Page ID (the numeric id of the page you admin). Falls back to FACEBOOK_PAGE_ID env var.",
|
|
57
|
+
),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
interface CredentialArgs {
|
|
61
|
+
accessToken?: string;
|
|
62
|
+
pageId?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function resolveCredentials(
|
|
66
|
+
args: CredentialArgs,
|
|
67
|
+
): { accessToken: string; pageId: string } | null {
|
|
68
|
+
const accessToken = args.accessToken || DEFAULT_ACCESS_TOKEN;
|
|
69
|
+
const pageId = args.pageId || DEFAULT_PAGE_ID;
|
|
70
|
+
if (!accessToken || !pageId) return null;
|
|
71
|
+
return { accessToken, pageId };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
type ClientResult =
|
|
75
|
+
| { ok: true; client: FacebookClient }
|
|
76
|
+
| { ok: false; error: ReturnType<typeof errorResult> };
|
|
77
|
+
|
|
78
|
+
export async function getClient(
|
|
79
|
+
args: CredentialArgs,
|
|
80
|
+
toolName?: string,
|
|
81
|
+
): Promise<ClientResult> {
|
|
82
|
+
const creds = resolveCredentials(args);
|
|
83
|
+
if (!creds) {
|
|
84
|
+
return {
|
|
85
|
+
ok: false,
|
|
86
|
+
error: errorResult(
|
|
87
|
+
"Missing credentials",
|
|
88
|
+
"Provide accessToken + pageId as arguments, or set FACEBOOK_ACCESS_TOKEN and FACEBOOK_PAGE_ID env vars.",
|
|
89
|
+
),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Pre-flight rate limit check (per-tenant, keyed by pageId)
|
|
94
|
+
const limit = await waitForRateLimit(toolName, creds.pageId);
|
|
95
|
+
if (!limit.allowed) {
|
|
96
|
+
const retryAfterSeconds = Math.ceil(limit.retryAfterMs / 1000);
|
|
97
|
+
return {
|
|
98
|
+
ok: false,
|
|
99
|
+
error: errorResult(
|
|
100
|
+
"Rate limited",
|
|
101
|
+
`Facebook API rate limit reached. Wait ${retryAfterSeconds}s then retry.`,
|
|
102
|
+
{
|
|
103
|
+
retryAfterSeconds,
|
|
104
|
+
action:
|
|
105
|
+
retryAfterSeconds <= 120
|
|
106
|
+
? `RETRY_AFTER_WAIT: Sleep ${retryAfterSeconds}s then retry this tool call.`
|
|
107
|
+
: `DEFER: Rate limit cooldown is ${retryAfterSeconds}s. Switch to a different task.`,
|
|
108
|
+
},
|
|
109
|
+
),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
const client = createClient(creds);
|
|
115
|
+
return { ok: true, client };
|
|
116
|
+
} catch (e) {
|
|
117
|
+
const msg = e instanceof Error ? e.message : "Unknown error";
|
|
118
|
+
return {
|
|
119
|
+
ok: false,
|
|
120
|
+
error: errorResult(
|
|
121
|
+
"Client error",
|
|
122
|
+
`Failed to create Facebook client: ${msg}`,
|
|
123
|
+
),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// --- Error handling ---
|
|
129
|
+
|
|
130
|
+
export function safeHandler<T>(
|
|
131
|
+
toolName: string,
|
|
132
|
+
handler: (
|
|
133
|
+
args: T,
|
|
134
|
+
) => Promise<ReturnType<typeof textResult | typeof senseResult>>,
|
|
135
|
+
): (
|
|
136
|
+
args: T,
|
|
137
|
+
) => Promise<
|
|
138
|
+
ReturnType<typeof textResult | typeof senseResult | typeof errorResult>
|
|
139
|
+
> {
|
|
140
|
+
return async (args: T) => {
|
|
141
|
+
try {
|
|
142
|
+
return await handler(args);
|
|
143
|
+
} catch (e) {
|
|
144
|
+
try {
|
|
145
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
146
|
+
const detail = extractApiDetail(e);
|
|
147
|
+
const statusCode = e instanceof FacebookApiError ? e.status : undefined;
|
|
148
|
+
const action = suggestAction(toolName, statusCode, detail, msg);
|
|
149
|
+
console.error(
|
|
150
|
+
`[${toolName}] Error: ${msg}${detail ? ` — ${detail}` : ""}`,
|
|
151
|
+
);
|
|
152
|
+
return errorResult(
|
|
153
|
+
"API error",
|
|
154
|
+
`${toolName} failed: ${detail || msg}`,
|
|
155
|
+
{
|
|
156
|
+
...(statusCode !== undefined && { statusCode }),
|
|
157
|
+
...(detail && detail !== msg && { rawError: msg }),
|
|
158
|
+
...(action && { action }),
|
|
159
|
+
},
|
|
160
|
+
);
|
|
161
|
+
} catch (formatErr) {
|
|
162
|
+
const fallback = e instanceof Error ? e.message : "Unknown error";
|
|
163
|
+
const fmtMsg =
|
|
164
|
+
formatErr instanceof Error ? formatErr.message : String(formatErr);
|
|
165
|
+
console.error(
|
|
166
|
+
`[${toolName}] Error (fallback): ${fallback} — error formatting also failed: ${fmtMsg}`,
|
|
167
|
+
);
|
|
168
|
+
return errorResult("API error", `${toolName} failed: ${fallback}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// --- Server Setup ---
|
|
175
|
+
|
|
176
|
+
const server = new McpServer({
|
|
177
|
+
name: "facebook-mcp-server",
|
|
178
|
+
version,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// =====================
|
|
182
|
+
// SENSE Tools (read)
|
|
183
|
+
// =====================
|
|
184
|
+
|
|
185
|
+
server.registerTool(
|
|
186
|
+
"fb_get_page_insights",
|
|
187
|
+
{
|
|
188
|
+
description:
|
|
189
|
+
"Get Facebook Page insights: impressions, reach, post engagements, and follower count over a period.",
|
|
190
|
+
inputSchema: {
|
|
191
|
+
...credentialFields,
|
|
192
|
+
period: z
|
|
193
|
+
.enum(["day", "week", "days_28"])
|
|
194
|
+
.optional()
|
|
195
|
+
.describe('Aggregation period (default: "day")'),
|
|
196
|
+
since: z
|
|
197
|
+
.string()
|
|
198
|
+
.optional()
|
|
199
|
+
.describe("Start date as Unix timestamp (e.g., '1700000000')"),
|
|
200
|
+
until: z.string().optional().describe("End date as Unix timestamp"),
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
safeHandler("fb_get_page_insights", async (args) => {
|
|
204
|
+
const result = await getClient(args, "fb_get_page_insights");
|
|
205
|
+
if (!result.ok) return result.error;
|
|
206
|
+
const { client } = result;
|
|
207
|
+
|
|
208
|
+
const data = await fetchPageInsights(client, {
|
|
209
|
+
period: args.period,
|
|
210
|
+
since: args.since,
|
|
211
|
+
until: args.until,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
return senseResult(data, "Facebook");
|
|
215
|
+
}),
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
server.registerTool(
|
|
219
|
+
"fb_get_post_insights",
|
|
220
|
+
{
|
|
221
|
+
description:
|
|
222
|
+
"Get engagement metrics for a specific Facebook post: impressions, engaged users, clicks, and reactions.",
|
|
223
|
+
inputSchema: {
|
|
224
|
+
...credentialFields,
|
|
225
|
+
postId: z
|
|
226
|
+
.string()
|
|
227
|
+
.describe(
|
|
228
|
+
"Facebook post ID (format: {page-id}_{post-id} or just post id)",
|
|
229
|
+
),
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
safeHandler("fb_get_post_insights", async (args) => {
|
|
233
|
+
if (!args.postId.trim())
|
|
234
|
+
return errorResult("Invalid input", "postId cannot be empty");
|
|
235
|
+
const result = await getClient(args, "fb_get_post_insights");
|
|
236
|
+
if (!result.ok) return result.error;
|
|
237
|
+
const { client } = result;
|
|
238
|
+
|
|
239
|
+
const data = await fetchPostInsights(client, { postId: args.postId });
|
|
240
|
+
return senseResult(data, "Facebook");
|
|
241
|
+
}),
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
server.registerTool(
|
|
245
|
+
"fb_get_comments",
|
|
246
|
+
{
|
|
247
|
+
description:
|
|
248
|
+
"Get comments on a Facebook post. Returns comment text, author name, and timestamps. Content is sanitized for safe agent consumption.",
|
|
249
|
+
inputSchema: {
|
|
250
|
+
...credentialFields,
|
|
251
|
+
postId: z.string().describe("Facebook post ID"),
|
|
252
|
+
limit: z
|
|
253
|
+
.number()
|
|
254
|
+
.optional()
|
|
255
|
+
.describe("Number of comments (default: 25, max: 100)"),
|
|
256
|
+
after: z.string().optional().describe("Pagination cursor"),
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
safeHandler("fb_get_comments", async (args) => {
|
|
260
|
+
if (!args.postId.trim())
|
|
261
|
+
return errorResult("Invalid input", "postId cannot be empty");
|
|
262
|
+
const result = await getClient(args, "fb_get_comments");
|
|
263
|
+
if (!result.ok) return result.error;
|
|
264
|
+
const { client } = result;
|
|
265
|
+
|
|
266
|
+
const params: Record<string, string> = {
|
|
267
|
+
fields: "id,message,from{id,name},created_time,like_count,comment_count",
|
|
268
|
+
limit: String(Math.min(args.limit || 25, 100)),
|
|
269
|
+
};
|
|
270
|
+
if (args.after) params.after = args.after;
|
|
271
|
+
|
|
272
|
+
const response = await withRetry(() =>
|
|
273
|
+
client.get<{
|
|
274
|
+
data: Array<{
|
|
275
|
+
id: string;
|
|
276
|
+
message?: string;
|
|
277
|
+
from?: { id: string; name: string };
|
|
278
|
+
created_time: string;
|
|
279
|
+
like_count?: number;
|
|
280
|
+
comment_count?: number;
|
|
281
|
+
}>;
|
|
282
|
+
paging?: { cursors?: { after?: string } };
|
|
283
|
+
}>(`/${args.postId}/comments`, params),
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
// Sanitize user-generated content
|
|
287
|
+
const comments = (response.data || []).map((c) => ({
|
|
288
|
+
id: c.id,
|
|
289
|
+
message: sanitize(c.message || ""),
|
|
290
|
+
author: c.from
|
|
291
|
+
? { id: c.from.id, name: sanitize(c.from.name) }
|
|
292
|
+
: undefined,
|
|
293
|
+
createdTime: c.created_time,
|
|
294
|
+
likeCount: c.like_count ?? 0,
|
|
295
|
+
replyCount: c.comment_count ?? 0,
|
|
296
|
+
}));
|
|
297
|
+
|
|
298
|
+
return senseResult(
|
|
299
|
+
{
|
|
300
|
+
postId: args.postId,
|
|
301
|
+
comments,
|
|
302
|
+
count: comments.length,
|
|
303
|
+
nextCursor: response.paging?.cursors?.after,
|
|
304
|
+
},
|
|
305
|
+
"Facebook",
|
|
306
|
+
);
|
|
307
|
+
}),
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
server.registerTool(
|
|
311
|
+
"fb_get_page_feed",
|
|
312
|
+
{
|
|
313
|
+
description:
|
|
314
|
+
"Get the Facebook Page's own feed (posts you've published). Returns post id, message, type, timestamp, shares, and permalink.",
|
|
315
|
+
inputSchema: {
|
|
316
|
+
...credentialFields,
|
|
317
|
+
limit: z
|
|
318
|
+
.number()
|
|
319
|
+
.optional()
|
|
320
|
+
.describe("Number of posts to fetch (default: 25, max: 100)"),
|
|
321
|
+
after: z.string().optional().describe("Pagination cursor"),
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
safeHandler("fb_get_page_feed", async (args) => {
|
|
325
|
+
const result = await getClient(args, "fb_get_page_feed");
|
|
326
|
+
if (!result.ok) return result.error;
|
|
327
|
+
const { client } = result;
|
|
328
|
+
|
|
329
|
+
const data = await fetchPageFeed(client, {
|
|
330
|
+
limit: args.limit,
|
|
331
|
+
after: args.after,
|
|
332
|
+
});
|
|
333
|
+
return senseResult(data, "Facebook");
|
|
334
|
+
}),
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
// =====================
|
|
338
|
+
// ACT Tools (write)
|
|
339
|
+
// =====================
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Optionally post a top-level comment on a just-created Facebook post (#1995).
|
|
343
|
+
* Returns { id } on success, { error } on failure, {} if no firstComment given.
|
|
344
|
+
* Mirrors the LinkedIn first-comment contract: 3-5s random delay, 2000 char cap.
|
|
345
|
+
*/
|
|
346
|
+
export async function postFirstComment(
|
|
347
|
+
client: FacebookClient,
|
|
348
|
+
postId: string,
|
|
349
|
+
firstComment: string | undefined,
|
|
350
|
+
): Promise<{ id?: string; error?: string }> {
|
|
351
|
+
const trimmed = firstComment?.trim();
|
|
352
|
+
if (!trimmed) return {};
|
|
353
|
+
const commentText = trimmed.slice(0, 2000);
|
|
354
|
+
try {
|
|
355
|
+
const delayMs = 3000 + Math.random() * 2000;
|
|
356
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
357
|
+
const commentResponse = await withRetry(() =>
|
|
358
|
+
client.post<{ id: string }>(`/${postId}/comments`, {
|
|
359
|
+
message: commentText,
|
|
360
|
+
}),
|
|
361
|
+
);
|
|
362
|
+
return { id: commentResponse.id };
|
|
363
|
+
} catch (e) {
|
|
364
|
+
const errMsg = e instanceof Error ? e.message : String(e);
|
|
365
|
+
console.error(`[fb_create_post] First comment failed: ${errMsg}`);
|
|
366
|
+
return { error: errMsg };
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
server.registerTool(
|
|
371
|
+
"fb_create_post",
|
|
372
|
+
{
|
|
373
|
+
description:
|
|
374
|
+
"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.",
|
|
375
|
+
inputSchema: {
|
|
376
|
+
...credentialFields,
|
|
377
|
+
message: z
|
|
378
|
+
.string()
|
|
379
|
+
.optional()
|
|
380
|
+
.describe(
|
|
381
|
+
"Post text. Required for text-only posts, optional for photo/video/link.",
|
|
382
|
+
),
|
|
383
|
+
link: z
|
|
384
|
+
.string()
|
|
385
|
+
.optional()
|
|
386
|
+
.describe(
|
|
387
|
+
"URL to share as a link preview. Mutually exclusive with imageUrl/videoUrl.",
|
|
388
|
+
),
|
|
389
|
+
imageUrl: z
|
|
390
|
+
.string()
|
|
391
|
+
.optional()
|
|
392
|
+
.describe(
|
|
393
|
+
"Publicly accessible image URL for a photo post. Mutually exclusive with link/videoUrl.",
|
|
394
|
+
),
|
|
395
|
+
videoUrl: z
|
|
396
|
+
.string()
|
|
397
|
+
.optional()
|
|
398
|
+
.describe(
|
|
399
|
+
"Publicly accessible video URL for a video post. Mutually exclusive with link/imageUrl.",
|
|
400
|
+
),
|
|
401
|
+
published: z
|
|
402
|
+
.boolean()
|
|
403
|
+
.optional()
|
|
404
|
+
.describe(
|
|
405
|
+
"Whether to publish immediately (default: true). Set false to create an unpublished draft.",
|
|
406
|
+
),
|
|
407
|
+
firstComment: z
|
|
408
|
+
.string()
|
|
409
|
+
.optional()
|
|
410
|
+
.describe(
|
|
411
|
+
"Optional follow-up comment posted ~a few seconds after the post for engagement (max 2000 chars). " +
|
|
412
|
+
"Facebook suppresses link-in-body reach so first-comment is the canonical place for a link/CTA. " +
|
|
413
|
+
"The comment is posted by the authenticated Page. Omit or leave blank to skip.",
|
|
414
|
+
),
|
|
415
|
+
},
|
|
416
|
+
},
|
|
417
|
+
safeHandler("fb_create_post", async (args) => {
|
|
418
|
+
const validation = buildCreatePostRequest(args);
|
|
419
|
+
if (!validation.ok) {
|
|
420
|
+
return errorResult(validation.error, validation.message, {
|
|
421
|
+
...(validation.action && { action: validation.action }),
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const result = await getClient(args, "fb_create_post");
|
|
426
|
+
if (!result.ok) return result.error;
|
|
427
|
+
const { client } = result;
|
|
428
|
+
|
|
429
|
+
const endpoint = validation.endpoint(client.pageId);
|
|
430
|
+
const response = await withRetry(() =>
|
|
431
|
+
client.post<{ id: string; post_id?: string }>(endpoint, validation.body),
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
// Photos/videos return { id, post_id } where post_id is the feed-level id
|
|
435
|
+
const postId = response.post_id || response.id;
|
|
436
|
+
|
|
437
|
+
// First comment (if provided). Partial-success: post is already live, so a
|
|
438
|
+
// comment failure must surface explicitly — never a clean success (#1931).
|
|
439
|
+
const fc = await postFirstComment(client, postId, args.firstComment);
|
|
440
|
+
if (fc.error) {
|
|
441
|
+
return errorResult(
|
|
442
|
+
"Partial failure",
|
|
443
|
+
`Post created (${postId}) but first comment failed: ${fc.error}. Use fb_reply_comment to retry.`,
|
|
444
|
+
{ id: postId, mediaId: response.id, type: validation.type },
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return textResult({
|
|
449
|
+
id: postId,
|
|
450
|
+
mediaId: response.id,
|
|
451
|
+
type: validation.type,
|
|
452
|
+
...(fc.id && { firstCommentId: fc.id }),
|
|
453
|
+
message: fc.id
|
|
454
|
+
? "Post created with first comment"
|
|
455
|
+
: "Post created successfully",
|
|
456
|
+
});
|
|
457
|
+
}),
|
|
458
|
+
);
|
|
459
|
+
|
|
460
|
+
server.registerTool(
|
|
461
|
+
"fb_reply_comment",
|
|
462
|
+
{
|
|
463
|
+
description: "Reply to a comment on a Facebook post.",
|
|
464
|
+
inputSchema: {
|
|
465
|
+
...credentialFields,
|
|
466
|
+
commentId: z.string().describe("ID of the comment to reply to"),
|
|
467
|
+
message: z.string().describe("Reply text"),
|
|
468
|
+
},
|
|
469
|
+
},
|
|
470
|
+
safeHandler("fb_reply_comment", async (args) => {
|
|
471
|
+
if (!args.commentId.trim())
|
|
472
|
+
return errorResult("Invalid input", "commentId cannot be empty");
|
|
473
|
+
if (!args.message.trim())
|
|
474
|
+
return errorResult("Invalid input", "message cannot be empty");
|
|
475
|
+
|
|
476
|
+
const result = await getClient(args, "fb_reply_comment");
|
|
477
|
+
if (!result.ok) return result.error;
|
|
478
|
+
const { client } = result;
|
|
479
|
+
|
|
480
|
+
const response = await withRetry(() =>
|
|
481
|
+
client.post<{ id: string }>(`/${args.commentId}/comments`, {
|
|
482
|
+
message: args.message,
|
|
483
|
+
}),
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
return textResult({
|
|
487
|
+
id: response.id,
|
|
488
|
+
parentCommentId: args.commentId,
|
|
489
|
+
message: "Reply posted successfully",
|
|
490
|
+
});
|
|
491
|
+
}),
|
|
492
|
+
);
|
|
493
|
+
|
|
494
|
+
server.registerTool(
|
|
495
|
+
"fb_delete_post",
|
|
496
|
+
{
|
|
497
|
+
description:
|
|
498
|
+
"Delete a Facebook Page post you published. The post ID must belong to the authenticated Page.",
|
|
499
|
+
inputSchema: {
|
|
500
|
+
...credentialFields,
|
|
501
|
+
postId: z.string().describe("ID of the post to delete"),
|
|
502
|
+
},
|
|
503
|
+
},
|
|
504
|
+
safeHandler("fb_delete_post", async (args) => {
|
|
505
|
+
if (!args.postId.trim())
|
|
506
|
+
return errorResult("Invalid input", "postId cannot be empty");
|
|
507
|
+
|
|
508
|
+
const result = await getClient(args, "fb_delete_post");
|
|
509
|
+
if (!result.ok) return result.error;
|
|
510
|
+
const { client } = result;
|
|
511
|
+
|
|
512
|
+
await withRetry(() => client.delete(`/${args.postId}`));
|
|
513
|
+
|
|
514
|
+
return textResult({
|
|
515
|
+
postId: args.postId,
|
|
516
|
+
message: "Post deleted successfully",
|
|
517
|
+
});
|
|
518
|
+
}),
|
|
519
|
+
);
|
|
520
|
+
|
|
521
|
+
// --- Start ---
|
|
522
|
+
|
|
523
|
+
export { server };
|
|
524
|
+
|
|
525
|
+
async function main() {
|
|
526
|
+
const transport = new StdioServerTransport();
|
|
527
|
+
await server.connect(transport);
|
|
528
|
+
console.error("Facebook MCP Server running on stdio");
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Only start the stdio transport when invoked directly, not when imported
|
|
532
|
+
// by test files. Compares import.meta.url to the script entrypoint.
|
|
533
|
+
const isDirectRun =
|
|
534
|
+
process.argv[1] && import.meta.url === `file://${process.argv[1]}`;
|
|
535
|
+
if (isDirectRun) {
|
|
536
|
+
main().catch((e) => {
|
|
537
|
+
console.error("Fatal:", e);
|
|
538
|
+
process.exit(1);
|
|
539
|
+
});
|
|
540
|
+
}
|
package/src/lib/index.ts
ADDED
|
@@ -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,150 @@
|
|
|
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
|
+
import { withRetry } from "../rate-limiter.js";
|
|
10
|
+
import { sanitize } from "../sanitize.js";
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// fb_get_page_insights
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
export interface PageInsightsArgs {
|
|
17
|
+
/** "day" | "week" | "days_28". Default: "day". */
|
|
18
|
+
period?: string;
|
|
19
|
+
/** Unix timestamp (string), inclusive. */
|
|
20
|
+
since?: string;
|
|
21
|
+
/** Unix timestamp (string), inclusive. */
|
|
22
|
+
until?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface PageInsightsResult {
|
|
26
|
+
/** Period echoed back from the request. */
|
|
27
|
+
period: string;
|
|
28
|
+
/** Raw Graph API insights array — one element per metric. */
|
|
29
|
+
insights: unknown[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const PAGE_INSIGHTS_METRICS =
|
|
33
|
+
"page_impressions_unique,page_post_engagements,page_views_total,page_follows";
|
|
34
|
+
|
|
35
|
+
export async function fetchPageInsights(
|
|
36
|
+
client: FacebookClient,
|
|
37
|
+
args: PageInsightsArgs = {},
|
|
38
|
+
): Promise<PageInsightsResult> {
|
|
39
|
+
const params: Record<string, string> = {
|
|
40
|
+
metric: PAGE_INSIGHTS_METRICS,
|
|
41
|
+
period: args.period || "day",
|
|
42
|
+
};
|
|
43
|
+
if (args.since) params.since = args.since;
|
|
44
|
+
if (args.until) params.until = args.until;
|
|
45
|
+
|
|
46
|
+
const response = await withRetry(() =>
|
|
47
|
+
client.get<{ data: unknown[] }>(`/${client.pageId}/insights`, params),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
return { insights: response.data, period: params.period };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// fb_get_post_insights
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
export interface PostInsightsArgs {
|
|
58
|
+
/** Facebook post ID, e.g. `{page-id}_{post-id}` or just post id. */
|
|
59
|
+
postId: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface PostInsightsResult {
|
|
63
|
+
postId: string;
|
|
64
|
+
insights: unknown[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const POST_INSIGHTS_METRICS =
|
|
68
|
+
"post_impressions_unique,post_clicks,post_reactions_by_type_total";
|
|
69
|
+
|
|
70
|
+
export async function fetchPostInsights(
|
|
71
|
+
client: FacebookClient,
|
|
72
|
+
args: PostInsightsArgs,
|
|
73
|
+
): Promise<PostInsightsResult> {
|
|
74
|
+
if (!args.postId.trim()) throw new Error("postId cannot be empty");
|
|
75
|
+
const response = await withRetry(() =>
|
|
76
|
+
client.get<{ data: unknown[] }>(`/${args.postId}/insights`, {
|
|
77
|
+
metric: POST_INSIGHTS_METRICS,
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
return { postId: args.postId, insights: response.data };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// fb_get_page_feed
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
export interface PageFeedArgs {
|
|
88
|
+
/** Default 25, max 100. */
|
|
89
|
+
limit?: number;
|
|
90
|
+
/** Pagination cursor. */
|
|
91
|
+
after?: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface FeedPost {
|
|
95
|
+
id: string;
|
|
96
|
+
message: string;
|
|
97
|
+
createdTime: string;
|
|
98
|
+
permalinkUrl?: string;
|
|
99
|
+
shareCount: number;
|
|
100
|
+
statusType?: string;
|
|
101
|
+
story?: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface PageFeedResult {
|
|
105
|
+
posts: FeedPost[];
|
|
106
|
+
count: number;
|
|
107
|
+
nextCursor?: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function fetchPageFeed(
|
|
111
|
+
client: FacebookClient,
|
|
112
|
+
args: PageFeedArgs = {},
|
|
113
|
+
): Promise<PageFeedResult> {
|
|
114
|
+
const params: Record<string, string> = {
|
|
115
|
+
fields: "id,message,created_time,permalink_url,shares,status_type,story",
|
|
116
|
+
limit: String(Math.min(args.limit || 25, 100)),
|
|
117
|
+
};
|
|
118
|
+
if (args.after) params.after = args.after;
|
|
119
|
+
|
|
120
|
+
const response = await withRetry(() =>
|
|
121
|
+
client.get<{
|
|
122
|
+
data: Array<{
|
|
123
|
+
id: string;
|
|
124
|
+
message?: string;
|
|
125
|
+
created_time: string;
|
|
126
|
+
permalink_url?: string;
|
|
127
|
+
shares?: { count: number };
|
|
128
|
+
status_type?: string;
|
|
129
|
+
story?: string;
|
|
130
|
+
}>;
|
|
131
|
+
paging?: { cursors?: { after?: string } };
|
|
132
|
+
}>(`/${client.pageId}/feed`, params),
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const posts: FeedPost[] = (response.data || []).map((p) => ({
|
|
136
|
+
id: p.id,
|
|
137
|
+
message: sanitize(p.message || ""),
|
|
138
|
+
createdTime: p.created_time,
|
|
139
|
+
permalinkUrl: p.permalink_url,
|
|
140
|
+
shareCount: p.shares?.count ?? 0,
|
|
141
|
+
statusType: p.status_type,
|
|
142
|
+
story: p.story ? sanitize(p.story) : undefined,
|
|
143
|
+
}));
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
posts,
|
|
147
|
+
count: posts.length,
|
|
148
|
+
nextCursor: response.paging?.cursors?.after,
|
|
149
|
+
};
|
|
150
|
+
}
|