instagram-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 (59) 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 +38 -0
  6. package/LICENSE +21 -0
  7. package/Makefile +16 -0
  8. package/README.md +141 -0
  9. package/dist/client.d.ts +57 -0
  10. package/dist/client.js +140 -0
  11. package/dist/client.test.d.ts +10 -0
  12. package/dist/client.test.js +212 -0
  13. package/dist/errors.d.ts +12 -0
  14. package/dist/errors.js +87 -0
  15. package/dist/errors.test.d.ts +9 -0
  16. package/dist/errors.test.js +164 -0
  17. package/dist/first-comment.test.d.ts +10 -0
  18. package/dist/first-comment.test.js +56 -0
  19. package/dist/handlers.test.d.ts +23 -0
  20. package/dist/handlers.test.js +355 -0
  21. package/dist/index.d.ts +49 -0
  22. package/dist/index.js +627 -0
  23. package/dist/lib/index.d.ts +6 -0
  24. package/dist/lib/index.js +5 -0
  25. package/dist/lib/insights.d.ts +55 -0
  26. package/dist/lib/insights.js +59 -0
  27. package/dist/rate-limiter.d.ts +72 -0
  28. package/dist/rate-limiter.js +219 -0
  29. package/dist/rate-limiter.test.d.ts +1 -0
  30. package/dist/rate-limiter.test.js +153 -0
  31. package/dist/response.d.ts +24 -0
  32. package/dist/response.js +35 -0
  33. package/dist/response.test.d.ts +1 -0
  34. package/dist/response.test.js +71 -0
  35. package/dist/sanitize.d.ts +17 -0
  36. package/dist/sanitize.js +27 -0
  37. package/dist/sanitize.test.d.ts +1 -0
  38. package/dist/sanitize.test.js +43 -0
  39. package/dist/tools.test.d.ts +14 -0
  40. package/dist/tools.test.js +188 -0
  41. package/package.json +32 -0
  42. package/src/client.test.ts +285 -0
  43. package/src/client.ts +204 -0
  44. package/src/errors.test.ts +299 -0
  45. package/src/errors.ts +108 -0
  46. package/src/first-comment.test.ts +75 -0
  47. package/src/handlers.test.ts +460 -0
  48. package/src/index.ts +960 -0
  49. package/src/lib/index.ts +6 -0
  50. package/src/lib/insights.ts +182 -0
  51. package/src/rate-limiter.test.ts +185 -0
  52. package/src/rate-limiter.ts +257 -0
  53. package/src/response.test.ts +80 -0
  54. package/src/response.ts +43 -0
  55. package/src/sanitize.test.ts +52 -0
  56. package/src/sanitize.ts +35 -0
  57. package/src/tools.test.ts +251 -0
  58. package/tsconfig.json +15 -0
  59. package/vitest.config.ts +10 -0
package/src/index.ts ADDED
@@ -0,0 +1,960 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Standalone Instagram MCP Server
4
+ *
5
+ * Dual-purpose SENSE + ACT server for the Instagram Graph API.
6
+ * Uses container-based publishing (create container → poll → publish).
7
+ *
8
+ * Tools:
9
+ * SENSE: ig_get_account_insights, ig_get_post_insights, ig_get_comments,
10
+ * ig_get_stories_insights, ig_get_audience_demographics, ig_get_hashtag_search
11
+ * ACT: ig_publish_photo, ig_publish_carousel, ig_publish_reel,
12
+ * ig_reply_comment, ig_delete_comment
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
+ InstagramApiError,
22
+ type InstagramClient,
23
+ } from "./client.js";
24
+ import { waitForRateLimit, withRetry, sleep } from "./rate-limiter.js";
25
+ import { sanitize } from "./sanitize.js";
26
+ import { extractApiDetail, suggestAction } from "./errors.js";
27
+ import {
28
+ fetchAccountInsights,
29
+ fetchPostInsights,
30
+ fetchAudienceDemographics,
31
+ } from "./lib/insights.js";
32
+ import { createRequire } from "node:module";
33
+
34
+ const require = createRequire(import.meta.url);
35
+ const { version } = require("../package.json") as { version: string };
36
+
37
+ // --- Env-based defaults ---
38
+
39
+ const DEFAULT_ACCESS_TOKEN = process.env.INSTAGRAM_ACCESS_TOKEN;
40
+ const DEFAULT_ACCOUNT_ID = process.env.INSTAGRAM_BUSINESS_ACCOUNT_ID;
41
+
42
+ // --- Credential resolution ---
43
+
44
+ const credentialFields = {
45
+ accessToken: z
46
+ .string()
47
+ .optional()
48
+ .describe(
49
+ "Instagram access token. Falls back to INSTAGRAM_ACCESS_TOKEN env var.",
50
+ ),
51
+ accountId: z
52
+ .string()
53
+ .optional()
54
+ .describe(
55
+ "Instagram Business Account ID. Falls back to INSTAGRAM_BUSINESS_ACCOUNT_ID env var.",
56
+ ),
57
+ };
58
+
59
+ interface CredentialArgs {
60
+ accessToken?: string;
61
+ accountId?: string;
62
+ }
63
+
64
+ export function resolveCredentials(
65
+ args: CredentialArgs,
66
+ ): { accessToken: string; accountId: string } | null {
67
+ const accessToken = args.accessToken || DEFAULT_ACCESS_TOKEN;
68
+ const accountId = args.accountId || DEFAULT_ACCOUNT_ID;
69
+ if (!accessToken || !accountId) return null;
70
+ return { accessToken, accountId };
71
+ }
72
+
73
+ type ClientResult =
74
+ | { ok: true; client: InstagramClient }
75
+ | { ok: false; error: ReturnType<typeof errorResult> };
76
+
77
+ export async function getClient(
78
+ args: CredentialArgs,
79
+ toolName?: string,
80
+ ): Promise<ClientResult> {
81
+ const creds = resolveCredentials(args);
82
+ if (!creds) {
83
+ return {
84
+ ok: false,
85
+ error: errorResult(
86
+ "Missing credentials",
87
+ "Provide accessToken + accountId as arguments, or set INSTAGRAM_ACCESS_TOKEN and INSTAGRAM_BUSINESS_ACCOUNT_ID env vars.",
88
+ ),
89
+ };
90
+ }
91
+
92
+ // Pre-flight rate limit check (per-tenant, keyed by accountId)
93
+ const limit = await waitForRateLimit(toolName, creds.accountId);
94
+ if (!limit.allowed) {
95
+ const retryAfterSeconds = Math.ceil(limit.retryAfterMs / 1000);
96
+ return {
97
+ ok: false,
98
+ error: errorResult(
99
+ "Rate limited",
100
+ `Instagram API rate limit reached. Wait ${retryAfterSeconds}s then retry.`,
101
+ {
102
+ retryAfterSeconds,
103
+ action:
104
+ retryAfterSeconds <= 120
105
+ ? `RETRY_AFTER_WAIT: Sleep ${retryAfterSeconds}s then retry this tool call.`
106
+ : `DEFER: Rate limit cooldown is ${retryAfterSeconds}s. Switch to a different task.`,
107
+ },
108
+ ),
109
+ };
110
+ }
111
+
112
+ try {
113
+ const client = createClient(creds);
114
+ return { ok: true, client };
115
+ } catch (e) {
116
+ const msg = e instanceof Error ? e.message : "Unknown error";
117
+ return {
118
+ ok: false,
119
+ error: errorResult(
120
+ "Client error",
121
+ `Failed to create Instagram client: ${msg}`,
122
+ ),
123
+ };
124
+ }
125
+ }
126
+
127
+ // --- Error handling ---
128
+
129
+ export function safeHandler<T>(
130
+ toolName: string,
131
+ handler: (
132
+ args: T,
133
+ ) => Promise<ReturnType<typeof textResult | typeof senseResult>>,
134
+ ): (
135
+ args: T,
136
+ ) => Promise<
137
+ ReturnType<typeof textResult | typeof senseResult | typeof errorResult>
138
+ > {
139
+ return async (args: T) => {
140
+ try {
141
+ return await handler(args);
142
+ } catch (e) {
143
+ try {
144
+ const msg = e instanceof Error ? e.message : String(e);
145
+ const detail = extractApiDetail(e);
146
+ const statusCode =
147
+ e instanceof InstagramApiError ? 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: "instagram-mcp-server",
178
+ version,
179
+ });
180
+
181
+ // =====================
182
+ // SENSE Tools (read)
183
+ // =====================
184
+
185
+ server.registerTool(
186
+ "ig_get_account_insights",
187
+ {
188
+ description:
189
+ "Get Instagram account insights: impressions, reach, follower growth, and profile views 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("ig_get_account_insights", async (args) => {
204
+ const result = await getClient(args, "ig_get_account_insights");
205
+ if (!result.ok) return result.error;
206
+ const { client } = result;
207
+
208
+ const data = await fetchAccountInsights(client, {
209
+ period: args.period,
210
+ since: args.since,
211
+ until: args.until,
212
+ });
213
+ return senseResult(data, "Instagram");
214
+ }),
215
+ );
216
+
217
+ server.registerTool(
218
+ "ig_get_post_insights",
219
+ {
220
+ description:
221
+ "Get engagement metrics for a specific Instagram post: impressions, reach, engagement, saves, shares.",
222
+ inputSchema: {
223
+ ...credentialFields,
224
+ mediaId: z.string().describe("Instagram media ID"),
225
+ },
226
+ },
227
+ safeHandler("ig_get_post_insights", async (args) => {
228
+ if (!args.mediaId.trim())
229
+ return errorResult("Invalid input", "mediaId cannot be empty");
230
+ const result = await getClient(args, "ig_get_post_insights");
231
+ if (!result.ok) return result.error;
232
+ const { client } = result;
233
+
234
+ const data = await fetchPostInsights(client, { mediaId: args.mediaId });
235
+ return senseResult(data, "Instagram");
236
+ }),
237
+ );
238
+
239
+ server.registerTool(
240
+ "ig_get_comments",
241
+ {
242
+ description:
243
+ "Get comments on an Instagram post. Returns comment text, username, and timestamp.",
244
+ inputSchema: {
245
+ ...credentialFields,
246
+ mediaId: z.string().describe("Instagram media ID"),
247
+ limit: z
248
+ .number()
249
+ .optional()
250
+ .describe("Number of comments (default: 25, max: 50)"),
251
+ after: z.string().optional().describe("Pagination cursor"),
252
+ },
253
+ },
254
+ safeHandler("ig_get_comments", async (args) => {
255
+ if (!args.mediaId.trim())
256
+ return errorResult("Invalid input", "mediaId cannot be empty");
257
+ const result = await getClient(args, "ig_get_comments");
258
+ if (!result.ok) return result.error;
259
+ const { client } = result;
260
+
261
+ const params: Record<string, string> = {
262
+ fields:
263
+ "id,text,username,timestamp,like_count,replies{id,text,username,timestamp}",
264
+ limit: String(Math.min(args.limit || 25, 50)),
265
+ };
266
+ if (args.after) params.after = args.after;
267
+
268
+ const response = await withRetry(() =>
269
+ client.get<{
270
+ data: Array<{
271
+ id: string;
272
+ text: string;
273
+ username: string;
274
+ timestamp: string;
275
+ like_count?: number;
276
+ replies?: {
277
+ data: Array<{
278
+ id: string;
279
+ text: string;
280
+ username: string;
281
+ timestamp: string;
282
+ }>;
283
+ };
284
+ }>;
285
+ paging?: { cursors?: { after?: string } };
286
+ }>(`/${args.mediaId}/comments`, params),
287
+ );
288
+
289
+ // Sanitize user-generated content
290
+ const comments = (response.data || []).map((c) => ({
291
+ id: c.id,
292
+ text: sanitize(c.text),
293
+ username: sanitize(c.username),
294
+ timestamp: c.timestamp,
295
+ likeCount: c.like_count ?? 0,
296
+ replies: c.replies?.data?.map((r) => ({
297
+ id: r.id,
298
+ text: sanitize(r.text),
299
+ username: sanitize(r.username),
300
+ timestamp: r.timestamp,
301
+ })),
302
+ }));
303
+
304
+ return senseResult(
305
+ {
306
+ mediaId: args.mediaId,
307
+ comments,
308
+ count: comments.length,
309
+ nextCursor: response.paging?.cursors?.after,
310
+ },
311
+ "Instagram",
312
+ );
313
+ }),
314
+ );
315
+
316
+ server.registerTool(
317
+ "ig_get_stories_insights",
318
+ {
319
+ description:
320
+ "Get insights for an active Instagram story: reach, replies, follows, profile_visits, total_interactions. Stories expire after 24 hours — storyId must reference a currently active story (fetch from /{ig-account-id}/stories).",
321
+ inputSchema: {
322
+ ...credentialFields,
323
+ storyId: z.string().describe("Instagram story media ID"),
324
+ },
325
+ },
326
+ safeHandler("ig_get_stories_insights", async (args) => {
327
+ if (!args.storyId.trim())
328
+ return errorResult("Invalid input", "storyId cannot be empty");
329
+ const result = await getClient(args, "ig_get_stories_insights");
330
+ if (!result.ok) return result.error;
331
+ const { client } = result;
332
+
333
+ const response = await withRetry(() =>
334
+ client.get<{ data: unknown[] }>(`/${args.storyId}/insights`, {
335
+ metric: "reach,replies,total_interactions",
336
+ }),
337
+ );
338
+
339
+ return senseResult(
340
+ { storyId: args.storyId, insights: response.data },
341
+ "Instagram",
342
+ );
343
+ }),
344
+ );
345
+
346
+ server.registerTool(
347
+ "ig_get_audience_demographics",
348
+ {
349
+ description:
350
+ "Get follower demographics: city, country, and age/gender breakdown. Requires 100+ followers.",
351
+ inputSchema: {
352
+ ...credentialFields,
353
+ metric: z
354
+ .enum([
355
+ "follower_demographics",
356
+ "engaged_audience_demographics",
357
+ "reached_audience_demographics",
358
+ ])
359
+ .optional()
360
+ .describe(
361
+ "Demographic metric (default: follower_demographics). Requires 100+ followers.",
362
+ ),
363
+ breakdown: z
364
+ .enum(["age", "city", "country", "gender"])
365
+ .optional()
366
+ .describe("Breakdown dimension (default: country)"),
367
+ },
368
+ },
369
+ safeHandler("ig_get_audience_demographics", async (args) => {
370
+ const result = await getClient(args, "ig_get_audience_demographics");
371
+ if (!result.ok) return result.error;
372
+ const { client } = result;
373
+
374
+ const data = await fetchAudienceDemographics(client, {
375
+ metric: args.metric,
376
+ breakdown: args.breakdown,
377
+ });
378
+ return senseResult(data, "Instagram");
379
+ }),
380
+ );
381
+
382
+ server.registerTool(
383
+ "ig_get_hashtag_search",
384
+ {
385
+ description:
386
+ "Search public Instagram posts by hashtag. Two-step: search hashtag ID → get recent media. Limited to 30 unique hashtags per 7-day rolling window.",
387
+ inputSchema: {
388
+ ...credentialFields,
389
+ hashtag: z.string().describe("Hashtag to search (without #)"),
390
+ limit: z
391
+ .number()
392
+ .optional()
393
+ .describe("Number of results (default: 25, max: 50)"),
394
+ },
395
+ },
396
+ safeHandler("ig_get_hashtag_search", async (args) => {
397
+ if (!args.hashtag.trim())
398
+ return errorResult("Invalid input", "hashtag cannot be empty");
399
+ const result = await getClient(args, "ig_get_hashtag_search");
400
+ if (!result.ok) return result.error;
401
+ const { client } = result;
402
+
403
+ // Step 1: Search for hashtag ID
404
+ const hashtagClean = args.hashtag.replace(/^#/, "").trim();
405
+ const searchResponse = await withRetry(() =>
406
+ client.get<{ data: Array<{ id: string }> }>("/ig_hashtag_search", {
407
+ q: hashtagClean,
408
+ user_id: client.accountId,
409
+ }),
410
+ );
411
+
412
+ if (!searchResponse.data?.[0]?.id) {
413
+ return errorResult(
414
+ "Hashtag not found",
415
+ `No results for hashtag "${hashtagClean}".`,
416
+ );
417
+ }
418
+
419
+ const hashtagId = searchResponse.data[0].id;
420
+
421
+ // Step 2: Get recent media for the hashtag
422
+ const mediaResponse = await withRetry(() =>
423
+ client.get<{
424
+ data: Array<{
425
+ id: string;
426
+ caption?: string;
427
+ media_type: string;
428
+ permalink?: string;
429
+ like_count?: number;
430
+ comments_count?: number;
431
+ timestamp: string;
432
+ }>;
433
+ }>(`/${hashtagId}/recent_media`, {
434
+ user_id: client.accountId,
435
+ fields:
436
+ "id,caption,media_type,permalink,like_count,comments_count,timestamp",
437
+ limit: String(Math.min(args.limit || 25, 50)),
438
+ }),
439
+ );
440
+
441
+ // Sanitize user-generated content
442
+ const media = (mediaResponse.data || []).map((m) => ({
443
+ id: m.id,
444
+ caption: m.caption ? sanitize(m.caption) : null,
445
+ mediaType: m.media_type,
446
+ permalink: m.permalink,
447
+ likeCount: m.like_count ?? 0,
448
+ commentsCount: m.comments_count ?? 0,
449
+ timestamp: m.timestamp,
450
+ }));
451
+
452
+ return senseResult(
453
+ {
454
+ hashtag: hashtagClean,
455
+ hashtagId,
456
+ media,
457
+ count: media.length,
458
+ },
459
+ "Instagram",
460
+ );
461
+ }),
462
+ );
463
+
464
+ // =====================
465
+ // ACT Tools (write)
466
+ // =====================
467
+
468
+ /**
469
+ * Poll a media container until its status is FINISHED or ERROR.
470
+ * Used for reels where video processing is async.
471
+ */
472
+ export async function pollContainerStatus(
473
+ client: InstagramClient,
474
+ containerId: string,
475
+ maxAttempts = 12,
476
+ initialWaitMs = 10_000,
477
+ ): Promise<string> {
478
+ let waitMs = initialWaitMs;
479
+ for (let i = 0; i < maxAttempts; i++) {
480
+ // Wait BEFORE checking — video processing never finishes instantly
481
+ await sleep(waitMs);
482
+ waitMs = Math.min(Math.ceil(waitMs * 1.5), 30_000);
483
+
484
+ const status = await client.get<{
485
+ status_code: string;
486
+ status?: string;
487
+ }>(`/${containerId}`, { fields: "status_code,status" });
488
+
489
+ console.error(
490
+ `[pollContainerStatus] Poll ${i + 1}/${maxAttempts}: ${status.status_code}`,
491
+ );
492
+
493
+ if (status.status_code === "FINISHED") return "FINISHED";
494
+ if (status.status_code === "ERROR") {
495
+ throw new Error(
496
+ `Container processing failed: ${status.status || "unknown error"}`,
497
+ );
498
+ }
499
+ // Anything other than IN_PROGRESS is an unexpected terminal state
500
+ if (status.status_code !== "IN_PROGRESS") {
501
+ throw new Error(
502
+ `Container ${containerId} has unexpected status "${status.status_code}": ${status.status || "no detail"}. This may indicate an expired or invalid container.`,
503
+ );
504
+ }
505
+ }
506
+
507
+ throw new Error(
508
+ `Container ${containerId} did not finish processing after ${maxAttempts} polls`,
509
+ );
510
+ }
511
+
512
+ /**
513
+ * Optionally post a top-level comment on a just-published Instagram media.
514
+ * Returns { id } on success, { error } on failure, {} if no firstComment given.
515
+ * Mirrors the LinkedIn first-comment contract: 3-5s random delay, 2200 char cap.
516
+ */
517
+ export async function postFirstComment(
518
+ client: InstagramClient,
519
+ mediaId: string,
520
+ firstComment: string | undefined,
521
+ ): Promise<{ id?: string; error?: string }> {
522
+ if (!firstComment?.trim()) return {};
523
+ const commentText = firstComment.trim().slice(0, 2200);
524
+ try {
525
+ const delayMs = 3000 + Math.random() * 2000;
526
+ console.error(
527
+ `[postFirstComment] Posting first comment in ${Math.round(delayMs / 1000)}s...`,
528
+ );
529
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
530
+ const response = await withRetry(() =>
531
+ client.post<{ id: string }>(`/${mediaId}/comments`, {
532
+ message: commentText,
533
+ }),
534
+ );
535
+ return { id: response.id };
536
+ } catch (e) {
537
+ const errMsg = e instanceof Error ? e.message : String(e);
538
+ console.error(`[postFirstComment] First comment failed: ${errMsg}`);
539
+ return { error: errMsg };
540
+ }
541
+ }
542
+
543
+ server.registerTool(
544
+ "ig_publish_photo",
545
+ {
546
+ description:
547
+ "Publish a photo post to Instagram. The image_url must be publicly accessible. Flow: create media container → publish.",
548
+ inputSchema: {
549
+ ...credentialFields,
550
+ imageUrl: z
551
+ .string()
552
+ .describe("Publicly accessible URL to the image. Must be JPEG or PNG."),
553
+ caption: z
554
+ .string()
555
+ .optional()
556
+ .describe(
557
+ "Post caption (max 2200 characters). Supports #hashtags and @mentions.",
558
+ ),
559
+ firstComment: z
560
+ .string()
561
+ .optional()
562
+ .describe(
563
+ "Optional first comment posted immediately after publishing. Use for hashtags or a call-to-action. NOTE: links in Instagram comments are NOT clickable — do not use for bare URLs. Requires an IG Business account. Max 2200 chars.",
564
+ ),
565
+ },
566
+ },
567
+ safeHandler("ig_publish_photo", async (args) => {
568
+ if (!args.imageUrl.trim())
569
+ return errorResult("Invalid input", "imageUrl cannot be empty");
570
+ if (args.caption && args.caption.length > 2200)
571
+ return errorResult(
572
+ "Invalid input",
573
+ `Caption is ${args.caption.length} chars, max is 2200.`,
574
+ );
575
+
576
+ const result = await getClient(args, "ig_publish_photo");
577
+ if (!result.ok) return result.error;
578
+ const { client } = result;
579
+
580
+ // Step 1: Create media container
581
+ const container = await withRetry(() =>
582
+ client.post<{ id: string }>(`/${client.accountId}/media`, {
583
+ image_url: args.imageUrl,
584
+ ...(args.caption && { caption: args.caption }),
585
+ }),
586
+ );
587
+
588
+ // Step 2: Wait for Meta to fetch and process the image.
589
+ // Photos usually finish in 1-5s but can take longer on slow image hosts.
590
+ const status = await pollContainerStatus(client, container.id, 20, 1500);
591
+ if (status !== "FINISHED") {
592
+ return errorResult(
593
+ "Container processing failed",
594
+ `Photo container ${container.id} ended in status ${status}. Check that imageUrl is publicly accessible and returns a valid JPEG/PNG.`,
595
+ {
596
+ action:
597
+ "INVALID_MEDIA: The image URL could not be processed. Verify it's publicly reachable, returns image/jpeg or image/png content-type, and is not behind a redirect.",
598
+ containerId: container.id,
599
+ },
600
+ );
601
+ }
602
+
603
+ // Step 3: Publish
604
+ const published = await withRetry(() =>
605
+ client.post<{ id: string }>(`/${client.accountId}/media_publish`, {
606
+ creation_id: container.id,
607
+ }),
608
+ );
609
+
610
+ // Step 4: Optional first comment
611
+ const commentResult = await postFirstComment(
612
+ client,
613
+ published.id,
614
+ args.firstComment,
615
+ );
616
+ if (commentResult.error) {
617
+ return errorResult(
618
+ "Partial failure",
619
+ `Media published (${published.id}) but first comment failed: ${commentResult.error}. Use ig_reply_comment to retry.`,
620
+ { id: published.id, containerId: container.id },
621
+ );
622
+ }
623
+
624
+ return textResult({
625
+ id: published.id,
626
+ containerId: container.id,
627
+ ...(commentResult.id && { firstCommentId: commentResult.id }),
628
+ message: commentResult.id
629
+ ? "Photo published with first comment"
630
+ : "Photo published successfully",
631
+ });
632
+ }),
633
+ );
634
+
635
+ server.registerTool(
636
+ "ig_publish_carousel",
637
+ {
638
+ description:
639
+ "Publish a carousel (multi-image) post to Instagram. Requires 2-10 items. Each item URL must be publicly accessible. Flow: create child containers → create parent container → publish.",
640
+ inputSchema: {
641
+ ...credentialFields,
642
+ items: z
643
+ .array(
644
+ z.object({
645
+ imageUrl: z.string().describe("Publicly accessible image URL"),
646
+ isVideo: z
647
+ .boolean()
648
+ .optional()
649
+ .describe("Set to true if this item is a video"),
650
+ }),
651
+ )
652
+ .min(2)
653
+ .max(10)
654
+ .describe(
655
+ "Carousel items (2-10). Each needs a publicly accessible media URL.",
656
+ ),
657
+ caption: z
658
+ .string()
659
+ .optional()
660
+ .describe("Carousel caption (max 2200 characters)."),
661
+ firstComment: z
662
+ .string()
663
+ .optional()
664
+ .describe(
665
+ "Optional first comment posted immediately after publishing. Use for hashtags or a call-to-action. NOTE: links in Instagram comments are NOT clickable — do not use for bare URLs. Requires an IG Business account. Max 2200 chars.",
666
+ ),
667
+ },
668
+ },
669
+ safeHandler("ig_publish_carousel", async (args) => {
670
+ if (args.caption && args.caption.length > 2200)
671
+ return errorResult(
672
+ "Invalid input",
673
+ `Caption is ${args.caption.length} chars, max is 2200.`,
674
+ );
675
+
676
+ const result = await getClient(args, "ig_publish_carousel");
677
+ if (!result.ok) return result.error;
678
+ const { client } = result;
679
+
680
+ // Step 1: Create child containers (with partial failure tracking)
681
+ const childIds: string[] = [];
682
+ for (let i = 0; i < args.items.length; i++) {
683
+ const item = args.items[i];
684
+ if (!item.imageUrl.trim()) {
685
+ return errorResult(
686
+ "Invalid input",
687
+ `Item ${i} in carousel has an empty URL.`,
688
+ {
689
+ action:
690
+ "FIX_INPUT: Provide a valid publicly accessible URL for each carousel item.",
691
+ },
692
+ );
693
+ }
694
+
695
+ try {
696
+ const child = await withRetry(() =>
697
+ client.post<{ id: string }>(`/${client.accountId}/media`, {
698
+ ...(item.isVideo
699
+ ? { media_type: "VIDEO", video_url: item.imageUrl }
700
+ : { media_type: "IMAGE", image_url: item.imageUrl }),
701
+ is_carousel_item: true,
702
+ }),
703
+ );
704
+
705
+ // Poll every child until FINISHED — both photos and videos can fail
706
+ // if Meta hasn't finished fetching the remote media.
707
+ console.error(
708
+ `[ig_publish_carousel] Polling child ${i + 1}/${args.items.length} (${child.id})...`,
709
+ );
710
+ const childStatus = await pollContainerStatus(
711
+ client,
712
+ child.id,
713
+ 20,
714
+ 1500,
715
+ );
716
+ if (childStatus !== "FINISHED") {
717
+ throw new Error(
718
+ `Child container ${child.id} ended in status ${childStatus}. Verify the media URL is publicly reachable and returns a valid content-type.`,
719
+ );
720
+ }
721
+
722
+ childIds.push(child.id);
723
+ } catch (e) {
724
+ const msg = e instanceof Error ? e.message : String(e);
725
+ return errorResult(
726
+ "Partial failure",
727
+ `Carousel creation failed at item ${i + 1}/${args.items.length}: ${msg}`,
728
+ {
729
+ action:
730
+ "PARTIAL_OPERATION: Child containers were created for items before the failure. These containers will auto-expire and do not need cleanup. Fix the failing item URL and retry the entire carousel. Note: retrying will consume another publish rate-limit token.",
731
+ createdContainerIds: childIds,
732
+ failedItemIndex: i,
733
+ totalItems: args.items.length,
734
+ },
735
+ );
736
+ }
737
+ }
738
+
739
+ // Step 2: Create parent carousel container
740
+ const parent = await withRetry(() =>
741
+ client.post<{ id: string }>(`/${client.accountId}/media`, {
742
+ media_type: "CAROUSEL",
743
+ children: childIds.join(","),
744
+ ...(args.caption && { caption: args.caption }),
745
+ }),
746
+ );
747
+
748
+ // Step 3: Publish
749
+ const published = await withRetry(() =>
750
+ client.post<{ id: string }>(`/${client.accountId}/media_publish`, {
751
+ creation_id: parent.id,
752
+ }),
753
+ );
754
+
755
+ // Step 4: Optional first comment
756
+ const commentResult = await postFirstComment(
757
+ client,
758
+ published.id,
759
+ args.firstComment,
760
+ );
761
+ if (commentResult.error) {
762
+ return errorResult(
763
+ "Partial failure",
764
+ `Media published (${published.id}) but first comment failed: ${commentResult.error}. Use ig_reply_comment to retry.`,
765
+ {
766
+ id: published.id,
767
+ containerId: parent.id,
768
+ childContainerIds: childIds,
769
+ itemCount: args.items.length,
770
+ },
771
+ );
772
+ }
773
+
774
+ return textResult({
775
+ id: published.id,
776
+ containerId: parent.id,
777
+ childContainerIds: childIds,
778
+ itemCount: args.items.length,
779
+ ...(commentResult.id && { firstCommentId: commentResult.id }),
780
+ message: commentResult.id
781
+ ? "Carousel published with first comment"
782
+ : "Carousel published successfully",
783
+ });
784
+ }),
785
+ );
786
+
787
+ server.registerTool(
788
+ "ig_publish_reel",
789
+ {
790
+ description:
791
+ "Publish a reel (short video) to Instagram. The video_url must be publicly accessible. Video processing is async — this tool polls until ready, then publishes.",
792
+ inputSchema: {
793
+ ...credentialFields,
794
+ videoUrl: z
795
+ .string()
796
+ .describe(
797
+ "Publicly accessible URL to the video. MP4 format recommended.",
798
+ ),
799
+ caption: z
800
+ .string()
801
+ .optional()
802
+ .describe("Reel caption (max 2200 characters)."),
803
+ coverUrl: z
804
+ .string()
805
+ .optional()
806
+ .describe("Publicly accessible URL for a custom cover image."),
807
+ shareToFeed: z
808
+ .boolean()
809
+ .optional()
810
+ .describe("Also share to the main feed (default: true)."),
811
+ firstComment: z
812
+ .string()
813
+ .optional()
814
+ .describe(
815
+ "Optional first comment posted immediately after publishing. Use for hashtags or a call-to-action. NOTE: links in Instagram comments are NOT clickable — do not use for bare URLs. Requires an IG Business account. Max 2200 chars.",
816
+ ),
817
+ },
818
+ },
819
+ safeHandler("ig_publish_reel", async (args) => {
820
+ if (!args.videoUrl.trim())
821
+ return errorResult("Invalid input", "videoUrl cannot be empty");
822
+ if (args.caption && args.caption.length > 2200)
823
+ return errorResult(
824
+ "Invalid input",
825
+ `Caption is ${args.caption.length} chars, max is 2200.`,
826
+ );
827
+
828
+ const result = await getClient(args, "ig_publish_reel");
829
+ if (!result.ok) return result.error;
830
+ const { client } = result;
831
+
832
+ // Step 1: Create reel container
833
+ const container = await withRetry(() =>
834
+ client.post<{ id: string }>(`/${client.accountId}/media`, {
835
+ media_type: "REELS",
836
+ video_url: args.videoUrl,
837
+ ...(args.caption && { caption: args.caption }),
838
+ ...(args.coverUrl && { cover_url: args.coverUrl }),
839
+ share_to_feed: args.shareToFeed !== false,
840
+ }),
841
+ );
842
+
843
+ // Step 2: Poll until video processing is complete
844
+ console.error(
845
+ `[ig_publish_reel] Polling container ${container.id} for processing status...`,
846
+ );
847
+ await pollContainerStatus(client, container.id);
848
+
849
+ // Step 3: Publish
850
+ const published = await withRetry(() =>
851
+ client.post<{ id: string }>(`/${client.accountId}/media_publish`, {
852
+ creation_id: container.id,
853
+ }),
854
+ );
855
+
856
+ // Step 4: Optional first comment
857
+ const commentResult = await postFirstComment(
858
+ client,
859
+ published.id,
860
+ args.firstComment,
861
+ );
862
+ if (commentResult.error) {
863
+ return errorResult(
864
+ "Partial failure",
865
+ `Media published (${published.id}) but first comment failed: ${commentResult.error}. Use ig_reply_comment to retry.`,
866
+ { id: published.id, containerId: container.id },
867
+ );
868
+ }
869
+
870
+ return textResult({
871
+ id: published.id,
872
+ containerId: container.id,
873
+ ...(commentResult.id && { firstCommentId: commentResult.id }),
874
+ message: commentResult.id
875
+ ? "Reel published with first comment"
876
+ : "Reel published successfully",
877
+ });
878
+ }),
879
+ );
880
+
881
+ server.registerTool(
882
+ "ig_reply_comment",
883
+ {
884
+ description: "Reply to a comment on an Instagram post.",
885
+ inputSchema: {
886
+ ...credentialFields,
887
+ commentId: z.string().describe("ID of the comment to reply to"),
888
+ message: z.string().describe("Reply text"),
889
+ },
890
+ },
891
+ safeHandler("ig_reply_comment", async (args) => {
892
+ if (!args.commentId.trim())
893
+ return errorResult("Invalid input", "commentId cannot be empty");
894
+ if (!args.message.trim())
895
+ return errorResult("Invalid input", "message cannot be empty");
896
+
897
+ const result = await getClient(args, "ig_reply_comment");
898
+ if (!result.ok) return result.error;
899
+ const { client } = result;
900
+
901
+ const response = await withRetry(() =>
902
+ client.post<{ id: string }>(`/${args.commentId}/replies`, {
903
+ message: args.message,
904
+ }),
905
+ );
906
+
907
+ return textResult({
908
+ id: response.id,
909
+ commentId: args.commentId,
910
+ message: "Reply posted successfully",
911
+ });
912
+ }),
913
+ );
914
+
915
+ server.registerTool(
916
+ "ig_delete_comment",
917
+ {
918
+ description: "Delete a comment on one of your Instagram posts.",
919
+ inputSchema: {
920
+ ...credentialFields,
921
+ commentId: z.string().describe("ID of the comment to delete"),
922
+ },
923
+ },
924
+ safeHandler("ig_delete_comment", async (args) => {
925
+ if (!args.commentId.trim())
926
+ return errorResult("Invalid input", "commentId cannot be empty");
927
+
928
+ const result = await getClient(args, "ig_delete_comment");
929
+ if (!result.ok) return result.error;
930
+ const { client } = result;
931
+
932
+ await withRetry(() => client.delete(`/${args.commentId}`));
933
+
934
+ return textResult({
935
+ commentId: args.commentId,
936
+ message: "Comment deleted successfully",
937
+ });
938
+ }),
939
+ );
940
+
941
+ // --- Start ---
942
+
943
+ export { server };
944
+
945
+ async function main() {
946
+ const transport = new StdioServerTransport();
947
+ await server.connect(transport);
948
+ console.error("Instagram MCP Server running on stdio");
949
+ }
950
+
951
+ // Only start the stdio transport when invoked directly, not when imported
952
+ // by test files. Compares import.meta.url to the script entrypoint.
953
+ const isDirectRun =
954
+ process.argv[1] && import.meta.url === `file://${process.argv[1]}`;
955
+ if (isDirectRun) {
956
+ main().catch((e) => {
957
+ console.error("Fatal:", e);
958
+ process.exit(1);
959
+ });
960
+ }