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/dist/client.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Facebook Graph API client.
|
|
3
|
+
*
|
|
4
|
+
* Raw fetch wrapper against https://graph.facebook.com/v21.0/.
|
|
5
|
+
* No SDK — keeps dependencies minimal (Occam's Razor).
|
|
6
|
+
* Client instances are cached by credential hash.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
const GRAPH_API_BASE = "https://graph.facebook.com/v21.0";
|
|
10
|
+
const CLIENT_TTL_MS = 4 * 60 * 60 * 1000; // 4 hours
|
|
11
|
+
const clientCache = new Map();
|
|
12
|
+
function credentialHash(creds) {
|
|
13
|
+
const raw = `${creds.accessToken}:${creds.pageId}`;
|
|
14
|
+
return createHash("sha256").update(raw).digest("hex").slice(0, 16);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Get or create a cached Facebook client.
|
|
18
|
+
*/
|
|
19
|
+
export function createClient(creds) {
|
|
20
|
+
const key = credentialHash(creds);
|
|
21
|
+
const now = Date.now();
|
|
22
|
+
// Evict stale entries on every call
|
|
23
|
+
for (const [k, v] of clientCache) {
|
|
24
|
+
if (now - v.createdAt >= CLIENT_TTL_MS) {
|
|
25
|
+
clientCache.delete(k);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const cached = clientCache.get(key);
|
|
29
|
+
if (cached)
|
|
30
|
+
return cached.client;
|
|
31
|
+
const client = new FacebookClient(creds);
|
|
32
|
+
clientCache.set(key, { client, createdAt: now });
|
|
33
|
+
return client;
|
|
34
|
+
}
|
|
35
|
+
function isGraphApiError(body) {
|
|
36
|
+
return (typeof body === "object" &&
|
|
37
|
+
body !== null &&
|
|
38
|
+
"error" in body &&
|
|
39
|
+
typeof body.error === "object" &&
|
|
40
|
+
typeof body.error.message === "string");
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Error thrown for Graph API failures. Carries structured error data
|
|
44
|
+
* for suggestAction() to inspect.
|
|
45
|
+
*/
|
|
46
|
+
export class FacebookApiError extends Error {
|
|
47
|
+
status;
|
|
48
|
+
code;
|
|
49
|
+
errorSubcode;
|
|
50
|
+
errorType;
|
|
51
|
+
retryAfter;
|
|
52
|
+
constructor(status, apiError, retryAfter) {
|
|
53
|
+
super(apiError.message);
|
|
54
|
+
this.name = "FacebookApiError";
|
|
55
|
+
this.status = status;
|
|
56
|
+
this.code = apiError.code;
|
|
57
|
+
this.errorSubcode = apiError.error_subcode;
|
|
58
|
+
this.errorType = apiError.type;
|
|
59
|
+
this.retryAfter = retryAfter;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export class FacebookClient {
|
|
63
|
+
accessToken;
|
|
64
|
+
pageId;
|
|
65
|
+
constructor(creds) {
|
|
66
|
+
this.accessToken = creds.accessToken;
|
|
67
|
+
this.pageId = creds.pageId;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Make a GET request to the Graph API.
|
|
71
|
+
*/
|
|
72
|
+
async get(path, params) {
|
|
73
|
+
const url = new URL(`${GRAPH_API_BASE}${path}`);
|
|
74
|
+
url.searchParams.set("access_token", this.accessToken);
|
|
75
|
+
if (params) {
|
|
76
|
+
for (const [key, value] of Object.entries(params)) {
|
|
77
|
+
url.searchParams.set(key, value);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const res = await fetch(url, {
|
|
81
|
+
signal: AbortSignal.timeout(30_000),
|
|
82
|
+
});
|
|
83
|
+
return this.handleResponse(res);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Make a POST request to the Graph API.
|
|
87
|
+
*/
|
|
88
|
+
async post(path, body) {
|
|
89
|
+
const url = new URL(`${GRAPH_API_BASE}${path}`);
|
|
90
|
+
url.searchParams.set("access_token", this.accessToken);
|
|
91
|
+
const res = await fetch(url, {
|
|
92
|
+
method: "POST",
|
|
93
|
+
headers: { "Content-Type": "application/json" },
|
|
94
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
95
|
+
signal: AbortSignal.timeout(30_000),
|
|
96
|
+
});
|
|
97
|
+
return this.handleResponse(res);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Make a DELETE request to the Graph API.
|
|
101
|
+
*/
|
|
102
|
+
async delete(path) {
|
|
103
|
+
const url = new URL(`${GRAPH_API_BASE}${path}`);
|
|
104
|
+
url.searchParams.set("access_token", this.accessToken);
|
|
105
|
+
const res = await fetch(url, {
|
|
106
|
+
method: "DELETE",
|
|
107
|
+
signal: AbortSignal.timeout(30_000),
|
|
108
|
+
});
|
|
109
|
+
return this.handleResponse(res);
|
|
110
|
+
}
|
|
111
|
+
async handleResponse(res) {
|
|
112
|
+
let body;
|
|
113
|
+
try {
|
|
114
|
+
body = await res.json();
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// Non-JSON response (HTML error page during outage, empty body, etc.)
|
|
118
|
+
throw new FacebookApiError(res.status, {
|
|
119
|
+
message: `HTTP ${res.status}: Response is not valid JSON (likely an outage or proxy error)`,
|
|
120
|
+
type: "ParseError",
|
|
121
|
+
code: res.status,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
if (!res.ok) {
|
|
125
|
+
const retryAfterHeader = res.headers.get("Retry-After");
|
|
126
|
+
const seconds = retryAfterHeader ? parseInt(retryAfterHeader, 10) : NaN;
|
|
127
|
+
const retryAfter = !isNaN(seconds) ? seconds : undefined;
|
|
128
|
+
if (isGraphApiError(body)) {
|
|
129
|
+
throw new FacebookApiError(res.status, body.error, retryAfter);
|
|
130
|
+
}
|
|
131
|
+
// Non-standard error response — wrap in a generic error
|
|
132
|
+
throw new FacebookApiError(res.status, {
|
|
133
|
+
message: `HTTP ${res.status}: ${JSON.stringify(body)}`,
|
|
134
|
+
type: "UnknownError",
|
|
135
|
+
code: res.status,
|
|
136
|
+
}, retryAfter);
|
|
137
|
+
}
|
|
138
|
+
return body;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the Graph API HTTP client.
|
|
3
|
+
*
|
|
4
|
+
* Uses vi.stubGlobal("fetch", ...) to stub network calls. Every test resets
|
|
5
|
+
* the stub in afterEach to prevent leakage. The most important test here is
|
|
6
|
+
* the one asserting GRAPH_API_BASE points at graph.facebook.com — it's a
|
|
7
|
+
* regression guard ensuring the correct API base URL is used.
|
|
8
|
+
*/
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the Graph API HTTP client.
|
|
3
|
+
*
|
|
4
|
+
* Uses vi.stubGlobal("fetch", ...) to stub network calls. Every test resets
|
|
5
|
+
* the stub in afterEach to prevent leakage. The most important test here is
|
|
6
|
+
* the one asserting GRAPH_API_BASE points at graph.facebook.com — it's a
|
|
7
|
+
* regression guard ensuring the correct API base URL is used.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
|
10
|
+
import { createClient, FacebookApiError, FacebookClient, } from "./client.js";
|
|
11
|
+
// Reach into the module's source to read the base URL constant.
|
|
12
|
+
// We assert on actual request URLs below, which is the real contract.
|
|
13
|
+
const creds = {
|
|
14
|
+
accessToken: "EAALtest123",
|
|
15
|
+
pageId: "548545275018321",
|
|
16
|
+
};
|
|
17
|
+
function mockFetchOk(body, init = {}) {
|
|
18
|
+
return vi.fn(async () => new Response(JSON.stringify(body), {
|
|
19
|
+
status: 200,
|
|
20
|
+
headers: { "content-type": "application/json" },
|
|
21
|
+
...init,
|
|
22
|
+
}));
|
|
23
|
+
}
|
|
24
|
+
function mockFetchError(status, body, headers = {}) {
|
|
25
|
+
return vi.fn(async () => new Response(JSON.stringify(body), {
|
|
26
|
+
status,
|
|
27
|
+
headers: { "content-type": "application/json", ...headers },
|
|
28
|
+
}));
|
|
29
|
+
}
|
|
30
|
+
describe("FacebookClient — URL construction", () => {
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
vi.unstubAllGlobals();
|
|
33
|
+
});
|
|
34
|
+
it("GET builds URL against graph.facebook.com/v21.0 (REGRESSION: Bug #1)", async () => {
|
|
35
|
+
const fetchMock = mockFetchOk({ data: [] });
|
|
36
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
37
|
+
const client = new FacebookClient(creds);
|
|
38
|
+
await client.get("/548545275018321/insights");
|
|
39
|
+
const [url] = fetchMock.mock.calls[0];
|
|
40
|
+
expect(url.toString()).toMatch(/^https:\/\/graph\.facebook\.com\/v21\.0\/548545275018321\/insights\?/);
|
|
41
|
+
});
|
|
42
|
+
it("GET puts access_token and extra params in query string", async () => {
|
|
43
|
+
const fetchMock = mockFetchOk({ data: [] });
|
|
44
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
45
|
+
const client = new FacebookClient(creds);
|
|
46
|
+
await client.get("/548545275018321/insights", {
|
|
47
|
+
metric: "reach,profile_views",
|
|
48
|
+
period: "day",
|
|
49
|
+
});
|
|
50
|
+
const url = fetchMock.mock.calls[0][0];
|
|
51
|
+
expect(url.searchParams.get("access_token")).toBe("EAALtest123");
|
|
52
|
+
expect(url.searchParams.get("metric")).toBe("reach,profile_views");
|
|
53
|
+
expect(url.searchParams.get("period")).toBe("day");
|
|
54
|
+
});
|
|
55
|
+
it("POST sends JSON body with correct Content-Type", async () => {
|
|
56
|
+
const fetchMock = mockFetchOk({ id: "new_media_id" });
|
|
57
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
58
|
+
const client = new FacebookClient(creds);
|
|
59
|
+
await client.post("/548545275018321/media", {
|
|
60
|
+
image_url: "https://example.com/test.jpg",
|
|
61
|
+
caption: "hello",
|
|
62
|
+
});
|
|
63
|
+
const [, init] = fetchMock.mock.calls[0];
|
|
64
|
+
expect(init?.method).toBe("POST");
|
|
65
|
+
const headers = init?.headers;
|
|
66
|
+
expect(headers["Content-Type"]).toBe("application/json");
|
|
67
|
+
expect(JSON.parse(init?.body)).toEqual({
|
|
68
|
+
image_url: "https://example.com/test.jpg",
|
|
69
|
+
caption: "hello",
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
it("POST includes access_token in query even when body is present", async () => {
|
|
73
|
+
const fetchMock = mockFetchOk({ id: "x" });
|
|
74
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
75
|
+
const client = new FacebookClient(creds);
|
|
76
|
+
await client.post("/548545275018321/media_publish", {
|
|
77
|
+
creation_id: "abc",
|
|
78
|
+
});
|
|
79
|
+
const url = fetchMock.mock.calls[0][0];
|
|
80
|
+
expect(url.searchParams.get("access_token")).toBe("EAALtest123");
|
|
81
|
+
});
|
|
82
|
+
it("DELETE sends DELETE method", async () => {
|
|
83
|
+
const fetchMock = mockFetchOk({ success: true });
|
|
84
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
85
|
+
const client = new FacebookClient(creds);
|
|
86
|
+
await client.delete("/comment_id_123");
|
|
87
|
+
const [, init] = fetchMock.mock.calls[0];
|
|
88
|
+
expect(init?.method).toBe("DELETE");
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
describe("FacebookClient — error handling", () => {
|
|
92
|
+
afterEach(() => {
|
|
93
|
+
vi.unstubAllGlobals();
|
|
94
|
+
});
|
|
95
|
+
it("parses Graph API error into FacebookApiError with status/code/subcode", async () => {
|
|
96
|
+
vi.stubGlobal("fetch", mockFetchError(400, {
|
|
97
|
+
error: {
|
|
98
|
+
message: "Invalid OAuth access token",
|
|
99
|
+
type: "OAuthException",
|
|
100
|
+
code: 190,
|
|
101
|
+
error_subcode: 460,
|
|
102
|
+
fbtrace_id: "abc123",
|
|
103
|
+
},
|
|
104
|
+
}));
|
|
105
|
+
const client = new FacebookClient(creds);
|
|
106
|
+
await expect(client.get("/me")).rejects.toMatchObject({
|
|
107
|
+
name: "FacebookApiError",
|
|
108
|
+
status: 400,
|
|
109
|
+
code: 190,
|
|
110
|
+
errorSubcode: 460,
|
|
111
|
+
errorType: "OAuthException",
|
|
112
|
+
message: "Invalid OAuth access token",
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
it("wraps non-JSON HTML error responses in FacebookApiError", async () => {
|
|
116
|
+
const fetchMock = vi.fn(async () => new Response("<html>502 Bad Gateway</html>", {
|
|
117
|
+
status: 502,
|
|
118
|
+
headers: { "content-type": "text/html" },
|
|
119
|
+
}));
|
|
120
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
121
|
+
const client = new FacebookClient(creds);
|
|
122
|
+
await expect(client.get("/me")).rejects.toMatchObject({
|
|
123
|
+
status: 502,
|
|
124
|
+
errorType: "ParseError",
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
it("passes through Retry-After header as retryAfter", async () => {
|
|
128
|
+
vi.stubGlobal("fetch", mockFetchError(429, {
|
|
129
|
+
error: {
|
|
130
|
+
message: "Rate limited",
|
|
131
|
+
type: "OAuthException",
|
|
132
|
+
code: 4,
|
|
133
|
+
},
|
|
134
|
+
}, { "Retry-After": "90" }));
|
|
135
|
+
const client = new FacebookClient(creds);
|
|
136
|
+
try {
|
|
137
|
+
await client.get("/me");
|
|
138
|
+
expect.fail("should have thrown");
|
|
139
|
+
}
|
|
140
|
+
catch (e) {
|
|
141
|
+
expect(e).toBeInstanceOf(FacebookApiError);
|
|
142
|
+
expect(e.retryAfter).toBe(90);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
it("wraps non-GraphAPI JSON error shapes in UnknownError", async () => {
|
|
146
|
+
vi.stubGlobal("fetch", mockFetchError(500, { something: "weird" }));
|
|
147
|
+
const client = new FacebookClient(creds);
|
|
148
|
+
await expect(client.get("/me")).rejects.toMatchObject({
|
|
149
|
+
status: 500,
|
|
150
|
+
errorType: "UnknownError",
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
describe("createClient — caching", () => {
|
|
155
|
+
beforeEach(() => {
|
|
156
|
+
// Clear module state between tests by re-requiring isn't straightforward
|
|
157
|
+
// in ESM. Instead we rely on the fact that each cache key includes the
|
|
158
|
+
// full token+account pair — using unique tokens per test keeps them
|
|
159
|
+
// isolated.
|
|
160
|
+
});
|
|
161
|
+
it("returns the same instance for identical credentials", () => {
|
|
162
|
+
const a = createClient({ accessToken: "tok_same", pageId: "page_1" });
|
|
163
|
+
const b = createClient({ accessToken: "tok_same", pageId: "page_1" });
|
|
164
|
+
expect(a).toBe(b);
|
|
165
|
+
});
|
|
166
|
+
it("returns different instances for different tokens", () => {
|
|
167
|
+
const a = createClient({ accessToken: "tok_A_unique", pageId: "page_x" });
|
|
168
|
+
const b = createClient({ accessToken: "tok_B_unique", pageId: "page_x" });
|
|
169
|
+
expect(a).not.toBe(b);
|
|
170
|
+
});
|
|
171
|
+
it("returns different instances for different account IDs", () => {
|
|
172
|
+
const a = createClient({ accessToken: "tok_shared", pageId: "page_1_u" });
|
|
173
|
+
const b = createClient({ accessToken: "tok_shared", pageId: "page_2_u" });
|
|
174
|
+
expect(a).not.toBe(b);
|
|
175
|
+
});
|
|
176
|
+
it("returned clients carry the provided creds", () => {
|
|
177
|
+
const client = createClient({
|
|
178
|
+
accessToken: "tok_readback",
|
|
179
|
+
pageId: "page_readback",
|
|
180
|
+
});
|
|
181
|
+
expect(client.accessToken).toBe("tok_readback");
|
|
182
|
+
expect(client.pageId).toBe("page_readback");
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
describe("FacebookApiError — shape", () => {
|
|
186
|
+
it("carries all graph-api error fields", () => {
|
|
187
|
+
const err = new FacebookApiError(400, {
|
|
188
|
+
message: "boom",
|
|
189
|
+
type: "OAuthException",
|
|
190
|
+
code: 100,
|
|
191
|
+
error_subcode: 2207052,
|
|
192
|
+
}, 30);
|
|
193
|
+
expect(err).toBeInstanceOf(Error);
|
|
194
|
+
expect(err.name).toBe("FacebookApiError");
|
|
195
|
+
expect(err.status).toBe(400);
|
|
196
|
+
expect(err.code).toBe(100);
|
|
197
|
+
expect(err.errorSubcode).toBe(2207052);
|
|
198
|
+
expect(err.errorType).toBe("OAuthException");
|
|
199
|
+
expect(err.retryAfter).toBe(30);
|
|
200
|
+
expect(err.message).toBe("boom");
|
|
201
|
+
});
|
|
202
|
+
it("allows undefined subcode and retryAfter", () => {
|
|
203
|
+
const err = new FacebookApiError(500, {
|
|
204
|
+
message: "server error",
|
|
205
|
+
type: "InternalError",
|
|
206
|
+
code: 1,
|
|
207
|
+
});
|
|
208
|
+
expect(err.errorSubcode).toBeUndefined();
|
|
209
|
+
expect(err.retryAfter).toBeUndefined();
|
|
210
|
+
});
|
|
211
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for fb_create_post — validation + request shaping.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from index.ts so the attachment-routing logic can be unit-tested
|
|
5
|
+
* without spinning up the MCP server or mocking HTTP. Every branch here has
|
|
6
|
+
* a test in create-post.test.ts.
|
|
7
|
+
*/
|
|
8
|
+
export interface CreatePostArgs {
|
|
9
|
+
message?: string;
|
|
10
|
+
link?: string;
|
|
11
|
+
imageUrl?: string;
|
|
12
|
+
videoUrl?: string;
|
|
13
|
+
published?: boolean;
|
|
14
|
+
}
|
|
15
|
+
export interface CreatePostValidationError {
|
|
16
|
+
ok: false;
|
|
17
|
+
error: string;
|
|
18
|
+
message: string;
|
|
19
|
+
action?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface CreatePostRequest {
|
|
22
|
+
ok: true;
|
|
23
|
+
endpoint: (pageId: string) => string;
|
|
24
|
+
body: Record<string, unknown>;
|
|
25
|
+
type: "text" | "link" | "photo" | "video";
|
|
26
|
+
}
|
|
27
|
+
export type CreatePostResult = CreatePostValidationError | CreatePostRequest;
|
|
28
|
+
export declare const FB_MESSAGE_MAX_LENGTH = 63206;
|
|
29
|
+
/**
|
|
30
|
+
* Validate fb_create_post arguments and return either a structured error
|
|
31
|
+
* or the request recipe (endpoint + body + content type) the tool should
|
|
32
|
+
* send to the Graph API.
|
|
33
|
+
*
|
|
34
|
+
* This function is pure — it has no side effects, does no IO, and does not
|
|
35
|
+
* depend on env vars or a client instance. The caller is responsible for
|
|
36
|
+
* substituting the real page id into `endpoint(pageId)` and actually
|
|
37
|
+
* dispatching the request.
|
|
38
|
+
*/
|
|
39
|
+
export declare function buildCreatePostRequest(args: CreatePostArgs): CreatePostResult;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for fb_create_post — validation + request shaping.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from index.ts so the attachment-routing logic can be unit-tested
|
|
5
|
+
* without spinning up the MCP server or mocking HTTP. Every branch here has
|
|
6
|
+
* a test in create-post.test.ts.
|
|
7
|
+
*/
|
|
8
|
+
// Facebook Page post hard cap per Meta docs.
|
|
9
|
+
export const FB_MESSAGE_MAX_LENGTH = 63206;
|
|
10
|
+
/**
|
|
11
|
+
* Validate fb_create_post arguments and return either a structured error
|
|
12
|
+
* or the request recipe (endpoint + body + content type) the tool should
|
|
13
|
+
* send to the Graph API.
|
|
14
|
+
*
|
|
15
|
+
* This function is pure — it has no side effects, does no IO, and does not
|
|
16
|
+
* depend on env vars or a client instance. The caller is responsible for
|
|
17
|
+
* substituting the real page id into `endpoint(pageId)` and actually
|
|
18
|
+
* dispatching the request.
|
|
19
|
+
*/
|
|
20
|
+
export function buildCreatePostRequest(args) {
|
|
21
|
+
const hasLink = !!args.link;
|
|
22
|
+
const hasImage = !!args.imageUrl;
|
|
23
|
+
const hasVideo = !!args.videoUrl;
|
|
24
|
+
const attachmentCount = [hasLink, hasImage, hasVideo].filter(Boolean).length;
|
|
25
|
+
// Rule 1: at most one attachment type
|
|
26
|
+
if (attachmentCount > 1) {
|
|
27
|
+
return {
|
|
28
|
+
ok: false,
|
|
29
|
+
error: "Invalid input",
|
|
30
|
+
message: "Exactly one of link, imageUrl, or videoUrl may be provided (or none for text-only).",
|
|
31
|
+
action: "FIX_INPUT: Pick a single attachment type per post. For multi-photo posts, fall back to calling fb_create_post once per photo.",
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
// Rule 2: must have SOMETHING
|
|
35
|
+
if (!args.message && attachmentCount === 0) {
|
|
36
|
+
return {
|
|
37
|
+
ok: false,
|
|
38
|
+
error: "Invalid input",
|
|
39
|
+
message: "A post must have at least a message, link, imageUrl, or videoUrl.",
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
// Rule 3: message length
|
|
43
|
+
if (args.message && args.message.length > FB_MESSAGE_MAX_LENGTH) {
|
|
44
|
+
return {
|
|
45
|
+
ok: false,
|
|
46
|
+
error: "Invalid input",
|
|
47
|
+
message: `Message is ${args.message.length} chars, Facebook max is ${FB_MESSAGE_MAX_LENGTH}.`,
|
|
48
|
+
action: "CAPTION_TOO_LONG: Shorten the message and retry.",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
// Build the request body
|
|
52
|
+
const body = {};
|
|
53
|
+
if (args.message)
|
|
54
|
+
body.message = args.message;
|
|
55
|
+
if (args.published === false)
|
|
56
|
+
body.published = false;
|
|
57
|
+
// Route to the correct endpoint based on attachment type
|
|
58
|
+
if (hasImage) {
|
|
59
|
+
body.url = args.imageUrl;
|
|
60
|
+
return {
|
|
61
|
+
ok: true,
|
|
62
|
+
endpoint: (pageId) => `/${pageId}/photos`,
|
|
63
|
+
body,
|
|
64
|
+
type: "photo",
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
if (hasVideo) {
|
|
68
|
+
body.file_url = args.videoUrl;
|
|
69
|
+
return {
|
|
70
|
+
ok: true,
|
|
71
|
+
endpoint: (pageId) => `/${pageId}/videos`,
|
|
72
|
+
body,
|
|
73
|
+
type: "video",
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
// Text-only OR link attachment — both go through /feed
|
|
77
|
+
if (hasLink)
|
|
78
|
+
body.link = args.link;
|
|
79
|
+
return {
|
|
80
|
+
ok: true,
|
|
81
|
+
endpoint: (pageId) => `/${pageId}/feed`,
|
|
82
|
+
body,
|
|
83
|
+
type: hasLink ? "link" : "text",
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for buildCreatePostRequest — the pure validation + routing
|
|
3
|
+
* helper backing fb_create_post.
|
|
4
|
+
*
|
|
5
|
+
* Each test is a regression guard for a specific branch of the tool's
|
|
6
|
+
* pre-flight logic. This closes the coverage gap identified in the Facebook
|
|
7
|
+
* MCP test audit: the 4 most FB-specific code paths (attachment routing,
|
|
8
|
+
* mutual-exclusion, caption length, empty-input) were previously only
|
|
9
|
+
* covered by live manual testing.
|
|
10
|
+
*/
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for buildCreatePostRequest — the pure validation + routing
|
|
3
|
+
* helper backing fb_create_post.
|
|
4
|
+
*
|
|
5
|
+
* Each test is a regression guard for a specific branch of the tool's
|
|
6
|
+
* pre-flight logic. This closes the coverage gap identified in the Facebook
|
|
7
|
+
* MCP test audit: the 4 most FB-specific code paths (attachment routing,
|
|
8
|
+
* mutual-exclusion, caption length, empty-input) were previously only
|
|
9
|
+
* covered by live manual testing.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, it, expect } from "vitest";
|
|
12
|
+
import { buildCreatePostRequest, FB_MESSAGE_MAX_LENGTH, } from "./create-post.js";
|
|
13
|
+
describe("buildCreatePostRequest — validation", () => {
|
|
14
|
+
it("rejects empty input (no message, no attachment)", () => {
|
|
15
|
+
const r = buildCreatePostRequest({});
|
|
16
|
+
expect(r.ok).toBe(false);
|
|
17
|
+
if (!r.ok) {
|
|
18
|
+
expect(r.error).toBe("Invalid input");
|
|
19
|
+
expect(r.message).toMatch(/at least a message, link, imageUrl, or videoUrl/);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
it("rejects when two attachment types are provided (link + imageUrl)", () => {
|
|
23
|
+
const r = buildCreatePostRequest({
|
|
24
|
+
message: "x",
|
|
25
|
+
link: "https://a.com",
|
|
26
|
+
imageUrl: "https://example.com/x.jpg",
|
|
27
|
+
});
|
|
28
|
+
expect(r.ok).toBe(false);
|
|
29
|
+
if (!r.ok) {
|
|
30
|
+
expect(r.message).toMatch(/Exactly one of link, imageUrl, or videoUrl/);
|
|
31
|
+
expect(r.action).toMatch(/^FIX_INPUT:/);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
it("rejects when all three attachment types are provided", () => {
|
|
35
|
+
const r = buildCreatePostRequest({
|
|
36
|
+
link: "https://a.com",
|
|
37
|
+
imageUrl: "https://example.com/x.jpg",
|
|
38
|
+
videoUrl: "https://example.com/x.mp4",
|
|
39
|
+
});
|
|
40
|
+
expect(r.ok).toBe(false);
|
|
41
|
+
if (!r.ok) {
|
|
42
|
+
expect(r.action).toMatch(/^FIX_INPUT:/);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
it("rejects message longer than Facebook's 63206 char limit", () => {
|
|
46
|
+
const r = buildCreatePostRequest({ message: "x".repeat(63207) });
|
|
47
|
+
expect(r.ok).toBe(false);
|
|
48
|
+
if (!r.ok) {
|
|
49
|
+
expect(r.message).toMatch(/63207 chars, Facebook max is 63206/);
|
|
50
|
+
expect(r.action).toMatch(/^CAPTION_TOO_LONG:/);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
it("accepts exactly 63206 chars (boundary condition)", () => {
|
|
54
|
+
const r = buildCreatePostRequest({
|
|
55
|
+
message: "x".repeat(FB_MESSAGE_MAX_LENGTH),
|
|
56
|
+
});
|
|
57
|
+
expect(r.ok).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
describe("buildCreatePostRequest — routing", () => {
|
|
61
|
+
it("routes text-only post to /{pageId}/feed with message", () => {
|
|
62
|
+
const r = buildCreatePostRequest({ message: "hello world" });
|
|
63
|
+
expect(r.ok).toBe(true);
|
|
64
|
+
if (r.ok) {
|
|
65
|
+
expect(r.type).toBe("text");
|
|
66
|
+
expect(r.endpoint("548545275018321")).toBe("/548545275018321/feed");
|
|
67
|
+
expect(r.body).toEqual({ message: "hello world" });
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
it("routes link post to /feed with link field", () => {
|
|
71
|
+
const r = buildCreatePostRequest({
|
|
72
|
+
message: "check this out",
|
|
73
|
+
link: "https://www.luminarylane.app",
|
|
74
|
+
});
|
|
75
|
+
expect(r.ok).toBe(true);
|
|
76
|
+
if (r.ok) {
|
|
77
|
+
expect(r.type).toBe("link");
|
|
78
|
+
expect(r.endpoint("548545275018321")).toBe("/548545275018321/feed");
|
|
79
|
+
expect(r.body).toEqual({
|
|
80
|
+
message: "check this out",
|
|
81
|
+
link: "https://www.luminarylane.app",
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
it("routes link post WITHOUT message to /feed (message optional for link)", () => {
|
|
86
|
+
const r = buildCreatePostRequest({ link: "https://www.luminarylane.app" });
|
|
87
|
+
expect(r.ok).toBe(true);
|
|
88
|
+
if (r.ok) {
|
|
89
|
+
expect(r.type).toBe("link");
|
|
90
|
+
expect(r.body).toEqual({ link: "https://www.luminarylane.app" });
|
|
91
|
+
expect(r.body.message).toBeUndefined();
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
it("routes photo post to /{pageId}/photos with url (not link)", () => {
|
|
95
|
+
const r = buildCreatePostRequest({
|
|
96
|
+
message: "a cat",
|
|
97
|
+
imageUrl: "https://example.com/cat.jpg",
|
|
98
|
+
});
|
|
99
|
+
expect(r.ok).toBe(true);
|
|
100
|
+
if (r.ok) {
|
|
101
|
+
expect(r.type).toBe("photo");
|
|
102
|
+
expect(r.endpoint("548545275018321")).toBe("/548545275018321/photos");
|
|
103
|
+
expect(r.body).toEqual({
|
|
104
|
+
message: "a cat",
|
|
105
|
+
url: "https://example.com/cat.jpg",
|
|
106
|
+
});
|
|
107
|
+
// Ensure we did NOT accidentally set the `link` field
|
|
108
|
+
expect(r.body.link).toBeUndefined();
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
it("routes photo post without caption", () => {
|
|
112
|
+
const r = buildCreatePostRequest({
|
|
113
|
+
imageUrl: "https://example.com/cat.jpg",
|
|
114
|
+
});
|
|
115
|
+
expect(r.ok).toBe(true);
|
|
116
|
+
if (r.ok) {
|
|
117
|
+
expect(r.type).toBe("photo");
|
|
118
|
+
expect(r.body.message).toBeUndefined();
|
|
119
|
+
expect(r.body.url).toBe("https://example.com/cat.jpg");
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
it("routes video post to /{pageId}/videos with file_url", () => {
|
|
123
|
+
const r = buildCreatePostRequest({
|
|
124
|
+
message: "watch",
|
|
125
|
+
videoUrl: "https://example.com/clip.mp4",
|
|
126
|
+
});
|
|
127
|
+
expect(r.ok).toBe(true);
|
|
128
|
+
if (r.ok) {
|
|
129
|
+
expect(r.type).toBe("video");
|
|
130
|
+
expect(r.endpoint("548545275018321")).toBe("/548545275018321/videos");
|
|
131
|
+
expect(r.body).toEqual({
|
|
132
|
+
message: "watch",
|
|
133
|
+
file_url: "https://example.com/clip.mp4",
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
it("passes published=false through to body when provided", () => {
|
|
138
|
+
const r = buildCreatePostRequest({
|
|
139
|
+
message: "draft",
|
|
140
|
+
published: false,
|
|
141
|
+
});
|
|
142
|
+
expect(r.ok).toBe(true);
|
|
143
|
+
if (r.ok) {
|
|
144
|
+
expect(r.body.published).toBe(false);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
it("does NOT set published when undefined (defaults to FB's true)", () => {
|
|
148
|
+
const r = buildCreatePostRequest({ message: "live" });
|
|
149
|
+
expect(r.ok).toBe(true);
|
|
150
|
+
if (r.ok) {
|
|
151
|
+
expect(r.body.published).toBeUndefined();
|
|
152
|
+
expect("published" in r.body).toBe(false);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
it("does NOT set published when explicitly true (let FB default stand)", () => {
|
|
156
|
+
const r = buildCreatePostRequest({
|
|
157
|
+
message: "live",
|
|
158
|
+
published: true,
|
|
159
|
+
});
|
|
160
|
+
expect(r.ok).toBe(true);
|
|
161
|
+
if (r.ok) {
|
|
162
|
+
expect(r.body.published).toBeUndefined();
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
describe("buildCreatePostRequest — endpoint is a pure function of pageId", () => {
|
|
167
|
+
it("endpoint builder returns different paths for different page ids", () => {
|
|
168
|
+
const r = buildCreatePostRequest({ message: "x" });
|
|
169
|
+
expect(r.ok).toBe(true);
|
|
170
|
+
if (r.ok) {
|
|
171
|
+
expect(r.endpoint("111")).toBe("/111/feed");
|
|
172
|
+
expect(r.endpoint("222")).toBe("/222/feed");
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
});
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
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
|
+
export declare function extractApiDetail(e: unknown): string | undefined;
|
|
12
|
+
export declare function suggestAction(toolName: string, statusCode: number | undefined, detail: string | undefined, errorMsg?: string): string | undefined;
|