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/errors.js ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Error mapping helpers — pure functions only, no IO.
3
+ *
4
+ * extractApiDetail() formats an FacebookApiError for human/agent consumption.
5
+ * suggestAction() maps (statusCode, detail) → an AGENT_ACTION hint string.
6
+ *
7
+ * These functions are pure and heavily unit-tested in errors.test.ts.
8
+ * Every hint string is a regression guard for a real bug discovered during
9
+ * live testing against the Facebook Graph API.
10
+ */
11
+ import { FacebookApiError } from "./client.js";
12
+ export function extractApiDetail(e) {
13
+ if (e instanceof FacebookApiError) {
14
+ const sub = e.errorSubcode ? ` (subcode: ${e.errorSubcode})` : "";
15
+ return `${e.errorType}: ${e.message}${sub}`;
16
+ }
17
+ return undefined;
18
+ }
19
+ export function suggestAction(toolName, statusCode, detail, errorMsg) {
20
+ const d = (detail || errorMsg || "").toLowerCase();
21
+ // Network-level errors
22
+ if (statusCode === undefined) {
23
+ if (d.includes("enotfound") || d.includes("dns"))
24
+ return "DNS_FAILURE: Cannot resolve graph.facebook.com. Check your internet connection.";
25
+ if (d.includes("timeout") || d.includes("abort"))
26
+ return "TIMEOUT: Request to Facebook timed out. Check your connection and retry.";
27
+ if (d.includes("econnrefused") || d.includes("econnreset"))
28
+ return "CONNECTION_FAILED: Cannot connect to Facebook. The service may be down. Retry in 30s.";
29
+ if (d.includes("fetch failed"))
30
+ return "NETWORK_ERROR: Network request failed. Check your internet connection and retry.";
31
+ return undefined;
32
+ }
33
+ // Token-invalidity errors can come back as 400 with an OAuthException.
34
+ // Match only explicit token-failure phrasing — NOT the bare word "oauth",
35
+ // because OAuthException is the generic error type for all Graph API failures.
36
+ if (d.includes("invalid oauth access token") ||
37
+ d.includes("access token has expired") ||
38
+ d.includes("session has expired") ||
39
+ d.includes("access token is invalid") ||
40
+ d.includes("cannot parse access token") ||
41
+ d.includes("malformed access token")) {
42
+ return "AUTH_FAILED: Access token is invalid or expired. Generate a new long-lived token via the Facebook Developer Console.";
43
+ }
44
+ switch (statusCode) {
45
+ case 400:
46
+ if ((d.includes("invalid") && d.includes("media")) ||
47
+ d.includes("photo or video can be accepted") ||
48
+ d.includes("2207052") ||
49
+ d.includes("2207027"))
50
+ return "INVALID_MEDIA: The media URL is invalid or inaccessible. Ensure the URL is publicly accessible, returns image/jpeg or image/png content-type, is not behind a redirect, and is not rate-limited by the host.";
51
+ if (d.includes("caption") || d.includes("too long"))
52
+ return "CAPTION_TOO_LONG: Caption exceeds 2200 characters. Shorten it and retry.";
53
+ if (d.includes("children") || d.includes("carousel"))
54
+ return "INVALID_CAROUSEL: Carousel requires 2-10 items. Check item count and media URLs.";
55
+ if (d.includes("hashtag"))
56
+ return "INVALID_HASHTAG: Hashtag search is limited to 30 unique hashtags per 7-day rolling window.";
57
+ return "INVALID_REQUEST: Check the error message and fix the input parameters.";
58
+ case 401:
59
+ return "AUTH_FAILED: Access token is invalid or expired. Generate a new long-lived token via the Facebook Developer Console.";
60
+ case 403:
61
+ if (d.includes("permission"))
62
+ return "PERMISSION_DENIED: Your token lacks the required permission. Check token permissions in Facebook Developer Console.";
63
+ if (d.includes("not approved") || d.includes("app not"))
64
+ return "APP_NOT_APPROVED: Your Facebook app needs approval for this permission. Submit for review in the App Dashboard.";
65
+ if (d.includes("business"))
66
+ return "BUSINESS_ACCOUNT_REQUIRED: This feature requires a Facebook Page linked to a Business account.";
67
+ return "FORBIDDEN: Facebook rejected this action. Check the error message for details.";
68
+ case 404: {
69
+ const t = toolName.toLowerCase();
70
+ if (t.includes("comment"))
71
+ return "COMMENT_NOT_FOUND: This comment may have been deleted. Skip it and move on.";
72
+ if (t.includes("post") || t.includes("insight"))
73
+ return "MEDIA_NOT_FOUND: This post may have been deleted or the ID is invalid. Skip it.";
74
+ if (t.includes("story"))
75
+ return "STORY_NOT_FOUND: Stories expire after 24 hours. This story is no longer available.";
76
+ return "NOT_FOUND: The requested resource does not exist. It may have been deleted.";
77
+ }
78
+ case 429:
79
+ return "RATE_LIMITED: Facebook rate limit hit after automatic retries. Wait 60s and retry, or switch to a different task.";
80
+ case 500:
81
+ case 502:
82
+ case 503:
83
+ return "SERVER_ERROR: Facebook is having issues. Wait 30s and retry once.";
84
+ default:
85
+ return undefined;
86
+ }
87
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Unit tests for error mapping helpers.
3
+ *
4
+ * Every test case here is a regression guard for a specific bug discovered
5
+ * during live testing against the real Facebook Graph API. Changing any of
6
+ * the asserted strings without updating the tool's error-handling docs is
7
+ * a contract break for agents consuming these responses.
8
+ */
9
+ export {};
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Unit tests for error mapping helpers.
3
+ *
4
+ * Every test case here is a regression guard for a specific bug discovered
5
+ * during live testing against the real Facebook Graph API. Changing any of
6
+ * the asserted strings without updating the tool's error-handling docs is
7
+ * a contract break for agents consuming these responses.
8
+ */
9
+ import { describe, it, expect } from "vitest";
10
+ import { extractApiDetail, suggestAction } from "./errors.js";
11
+ import { FacebookApiError } from "./client.js";
12
+ describe("extractApiDetail", () => {
13
+ it("returns undefined for non-FacebookApiError values", () => {
14
+ expect(extractApiDetail(new Error("boom"))).toBeUndefined();
15
+ expect(extractApiDetail("string error")).toBeUndefined();
16
+ expect(extractApiDetail(null)).toBeUndefined();
17
+ expect(extractApiDetail(undefined)).toBeUndefined();
18
+ });
19
+ it("formats FacebookApiError without subcode", () => {
20
+ const err = new FacebookApiError(400, {
21
+ message: "Invalid OAuth access token - Cannot parse access token",
22
+ type: "OAuthException",
23
+ code: 190,
24
+ });
25
+ expect(extractApiDetail(err)).toBe("OAuthException: Invalid OAuth access token - Cannot parse access token");
26
+ });
27
+ it("formats FacebookApiError with subcode", () => {
28
+ const err = new FacebookApiError(400, {
29
+ message: "Media ID is not available",
30
+ type: "OAuthException",
31
+ code: 100,
32
+ error_subcode: 2207027,
33
+ });
34
+ expect(extractApiDetail(err)).toBe("OAuthException: Media ID is not available (subcode: 2207027)");
35
+ });
36
+ });
37
+ describe("suggestAction — network errors (no statusCode)", () => {
38
+ it("ENOTFOUND → DNS_FAILURE", () => {
39
+ const r = suggestAction("fb_get_page_insights", undefined, undefined, "getaddrinfo ENOTFOUND graph.facebook.com");
40
+ expect(r).toMatch(/^DNS_FAILURE:/);
41
+ });
42
+ it("timeout → TIMEOUT", () => {
43
+ expect(suggestAction("fb_get_comments", undefined, undefined, "The operation was aborted due to timeout")).toMatch(/^TIMEOUT:/);
44
+ });
45
+ it("ECONNREFUSED → CONNECTION_FAILED", () => {
46
+ expect(suggestAction("fb_get_comments", undefined, undefined, "connect ECONNREFUSED 31.13.84.4:443")).toMatch(/^CONNECTION_FAILED:/);
47
+ });
48
+ it("fetch failed → NETWORK_ERROR", () => {
49
+ expect(suggestAction("fb_get_comments", undefined, undefined, "fetch failed")).toMatch(/^NETWORK_ERROR:/);
50
+ });
51
+ it("unknown network error → undefined", () => {
52
+ expect(suggestAction("fb_get_comments", undefined, undefined, "something weird")).toBeUndefined();
53
+ });
54
+ });
55
+ describe("suggestAction — token errors (high priority for Bug #6)", () => {
56
+ it("'Invalid OAuth access token' → AUTH_FAILED", () => {
57
+ const r = suggestAction("fb_get_page_insights", 400, "OAuthException: Invalid OAuth access token - Cannot parse access token");
58
+ expect(r).toMatch(/^AUTH_FAILED:/);
59
+ });
60
+ it("'Cannot parse access token' → AUTH_FAILED", () => {
61
+ expect(suggestAction("any", 400, "Cannot parse access token")).toMatch(/^AUTH_FAILED:/);
62
+ });
63
+ it("'access token has expired' → AUTH_FAILED", () => {
64
+ expect(suggestAction("any", 400, "Error validating access token: access token has expired")).toMatch(/^AUTH_FAILED:/);
65
+ });
66
+ it("'session has expired' → AUTH_FAILED", () => {
67
+ expect(suggestAction("any", 400, "The session has expired on Tuesday")).toMatch(/^AUTH_FAILED:/);
68
+ });
69
+ it("'malformed access token' → AUTH_FAILED", () => {
70
+ expect(suggestAction("any", 400, "Malformed access token")).toMatch(/^AUTH_FAILED:/);
71
+ });
72
+ // REGRESSION: generic OAuthException for metric errors must NOT be AUTH_FAILED
73
+ it("metric error mentioning OAuthException is NOT AUTH_FAILED", () => {
74
+ const metricErr = "OAuthException: (#100) metric[2] must be one of the following values: reach, follower_count, profile_views";
75
+ const r = suggestAction("fb_get_page_insights", 400, metricErr);
76
+ expect(r).not.toMatch(/^AUTH_FAILED:/);
77
+ expect(r).toMatch(/^INVALID_REQUEST:/);
78
+ });
79
+ });
80
+ describe("suggestAction — 400 media errors (regression guard for Bug #9)", () => {
81
+ it("'photo or video can be accepted' → INVALID_MEDIA", () => {
82
+ const r = suggestAction("fb_create_post", 400, "OAuthException: Only photo or video can be accepted as media type. (subcode: 2207052)");
83
+ expect(r).toMatch(/^INVALID_MEDIA:/);
84
+ });
85
+ it("subcode 2207052 → INVALID_MEDIA", () => {
86
+ expect(suggestAction("fb_create_post", 400, "Some error text (subcode: 2207052)")).toMatch(/^INVALID_MEDIA:/);
87
+ });
88
+ it("subcode 2207027 → INVALID_MEDIA", () => {
89
+ expect(suggestAction("fb_create_post", 400, "Media ID is not available (subcode: 2207027)")).toMatch(/^INVALID_MEDIA:/);
90
+ });
91
+ it("'invalid media URL' → INVALID_MEDIA", () => {
92
+ expect(suggestAction("fb_create_post", 400, "Invalid media URL provided")).toMatch(/^INVALID_MEDIA:/);
93
+ });
94
+ });
95
+ describe("suggestAction — other 400 branches", () => {
96
+ it("caption mention → CAPTION_TOO_LONG", () => {
97
+ expect(suggestAction("fb_create_post", 400, "Caption is too long")).toMatch(/^CAPTION_TOO_LONG:/);
98
+ });
99
+ it("'too long' → CAPTION_TOO_LONG", () => {
100
+ expect(suggestAction("fb_create_post", 400, "Value too long")).toMatch(/^CAPTION_TOO_LONG:/);
101
+ });
102
+ it("carousel children error → INVALID_CAROUSEL", () => {
103
+ expect(suggestAction("fb_create_post", 400, "Carousel children must be between 2 and 10")).toMatch(/^INVALID_CAROUSEL:/);
104
+ });
105
+ it("hashtag error → INVALID_HASHTAG", () => {
106
+ expect(suggestAction("fb_get_page_feed", 400, "Hashtag quota exceeded")).toMatch(/^INVALID_HASHTAG:/);
107
+ });
108
+ it("unmatched 400 → INVALID_REQUEST", () => {
109
+ expect(suggestAction("any", 400, "Unknown bad request reason")).toMatch(/^INVALID_REQUEST:/);
110
+ });
111
+ });
112
+ describe("suggestAction — 401/403/404", () => {
113
+ it("401 → AUTH_FAILED", () => {
114
+ expect(suggestAction("any", 401, "anything")).toMatch(/^AUTH_FAILED:/);
115
+ });
116
+ it("403 with 'permission' → PERMISSION_DENIED", () => {
117
+ expect(suggestAction("any", 403, "You lack the required permission")).toMatch(/^PERMISSION_DENIED:/);
118
+ });
119
+ it("403 with 'not approved' → APP_NOT_APPROVED", () => {
120
+ expect(suggestAction("any", 403, "This feature is not approved for your app")).toMatch(/^APP_NOT_APPROVED:/);
121
+ });
122
+ it("403 with 'business' → BUSINESS_ACCOUNT_REQUIRED", () => {
123
+ expect(suggestAction("any", 403, "A business account is required")).toMatch(/^BUSINESS_ACCOUNT_REQUIRED:/);
124
+ });
125
+ it("403 unmatched → FORBIDDEN", () => {
126
+ expect(suggestAction("any", 403, "generic denial")).toMatch(/^FORBIDDEN:/);
127
+ });
128
+ it("404 comment tool → COMMENT_NOT_FOUND", () => {
129
+ expect(suggestAction("fb_reply_comment", 404, "not found")).toMatch(/^COMMENT_NOT_FOUND:/);
130
+ });
131
+ it("404 post tool (delete) → MEDIA_NOT_FOUND", () => {
132
+ expect(suggestAction("fb_delete_post", 404, "not found")).toMatch(/^MEDIA_NOT_FOUND:/);
133
+ });
134
+ it("404 insight tool → MEDIA_NOT_FOUND", () => {
135
+ expect(suggestAction("fb_get_post_insights", 404, "not found")).toMatch(/^MEDIA_NOT_FOUND:/);
136
+ });
137
+ // Story branch is inherited from the shared errors.ts but Facebook has no
138
+ // story tools in scope — kept as a future-proofing regression guard.
139
+ it("404 story tool → STORY_NOT_FOUND (future-proofing)", () => {
140
+ expect(suggestAction("some_story_tool", 404, "not found")).toMatch(/^STORY_NOT_FOUND:/);
141
+ });
142
+ it("404 generic tool → NOT_FOUND", () => {
143
+ expect(suggestAction("fb_misc_tool", 404, "not found")).toMatch(/^NOT_FOUND:/);
144
+ });
145
+ });
146
+ describe("suggestAction — 429 and 5xx", () => {
147
+ it("429 → RATE_LIMITED", () => {
148
+ expect(suggestAction("any", 429, "rate limited")).toMatch(/^RATE_LIMITED:/);
149
+ });
150
+ it("500 → SERVER_ERROR", () => {
151
+ expect(suggestAction("any", 500, "internal server error")).toMatch(/^SERVER_ERROR:/);
152
+ });
153
+ it("502 → SERVER_ERROR", () => {
154
+ expect(suggestAction("any", 502, "bad gateway")).toMatch(/^SERVER_ERROR:/);
155
+ });
156
+ it("503 → SERVER_ERROR", () => {
157
+ expect(suggestAction("any", 503, "service unavailable")).toMatch(/^SERVER_ERROR:/);
158
+ });
159
+ it("unknown status → undefined", () => {
160
+ expect(suggestAction("any", 418, "I'm a teapot")).toBeUndefined();
161
+ });
162
+ });
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Regression tests for the #1995 first-comment partial-success contract (#1931).
3
+ *
4
+ * Facebook is the highest-value first-comment platform (FB suppresses
5
+ * link-in-body reach, so the link belongs in the first comment). If the post
6
+ * publishes but the comment fails, fb_create_post must report a partial failure
7
+ * carrying the live post id — never a clean success. postFirstComment is the
8
+ * isolated step the handler delegates to.
9
+ */
10
+ export {};
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Regression tests for the #1995 first-comment partial-success contract (#1931).
3
+ *
4
+ * Facebook is the highest-value first-comment platform (FB suppresses
5
+ * link-in-body reach, so the link belongs in the first comment). If the post
6
+ * publishes but the comment fails, fb_create_post must report a partial failure
7
+ * carrying the live post id — never a clean success. postFirstComment is the
8
+ * isolated step the handler delegates to.
9
+ */
10
+ import { describe, it, expect, vi, afterEach } from "vitest";
11
+ import { postFirstComment } from "./index.js";
12
+ afterEach(() => {
13
+ vi.useRealTimers();
14
+ vi.restoreAllMocks();
15
+ });
16
+ async function runWithTimers(p) {
17
+ await vi.runAllTimersAsync();
18
+ return p;
19
+ }
20
+ describe("postFirstComment — partial-success contract", () => {
21
+ it("returns the comment id when the comment posts", async () => {
22
+ vi.useFakeTimers();
23
+ const post = vi.fn().mockResolvedValue({ id: "comment-1" });
24
+ const client = { post };
25
+ const result = await runWithTimers(postFirstComment(client, "post-1", "Link in the comments 👇"));
26
+ expect(result).toEqual({ id: "comment-1" });
27
+ expect(post).toHaveBeenCalledWith("/post-1/comments", {
28
+ message: "Link in the comments 👇",
29
+ });
30
+ });
31
+ it("returns { error } (NOT a clean success) when the comment client throws", async () => {
32
+ vi.useFakeTimers();
33
+ const post = vi.fn().mockRejectedValue(new Error("graph boom"));
34
+ const client = { post };
35
+ const result = await runWithTimers(postFirstComment(client, "post-1", "hi"));
36
+ expect(result.error).toContain("graph boom");
37
+ expect(result.id).toBeUndefined();
38
+ });
39
+ it("is a no-op (no API call) when firstComment is blank", async () => {
40
+ const post = vi.fn();
41
+ const client = { post };
42
+ expect(await postFirstComment(client, "post-1", " ")).toEqual({});
43
+ expect(await postFirstComment(client, "post-1", undefined)).toEqual({});
44
+ expect(post).not.toHaveBeenCalled();
45
+ });
46
+ it("caps the comment at Facebook's 2000-char limit", async () => {
47
+ vi.useFakeTimers();
48
+ const post = vi.fn().mockResolvedValue({ id: "c" });
49
+ const client = { post };
50
+ await runWithTimers(postFirstComment(client, "p", "y".repeat(4000)));
51
+ const sent = post.mock.calls[0][1];
52
+ expect(sent.message).toHaveLength(2000);
53
+ });
54
+ });
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Integration tests for tool handler bodies.
3
+ *
4
+ * Closes the coverage gap identified in the test audit: the 7 tool handlers
5
+ * in index.ts (their metric lists, field selectors, URL construction, response
6
+ * mapping, and sanitization wiring) were previously only covered by live
7
+ * manual testing. These tests reach the registered handler functions via the
8
+ * MCP server's internal registry (`_registeredTools[name].handler`) and
9
+ * invoke them directly with stubbed fetch.
10
+ *
11
+ * Each tool gets at least one happy-path test (correct URL + fields + response
12
+ * shape) and one error-path test (API error → structured error with action).
13
+ *
14
+ * Note: reaching into `_registeredTools` relies on an SDK internal. This is
15
+ * acceptable for tests because (a) SDK version is pinned, (b) a breaking
16
+ * change would fail immediately and loudly, and (c) it avoids a larger
17
+ * refactor of index.ts just for test plumbing.
18
+ */
19
+ export {};