Claude can write an Instagram caption, but it cannot post one until a connector gives it a way in. Most pages on this search answer with one product's connector, which posts through that product's app after you link your Instagram account to it. This guide builds the other answer: a small MCP server on your own computer that calls Meta's official Instagram API, with Claude Desktop asking you before anything goes live.
The API calls are the ones from our Instagram API guide, where each call was run on a real account. Read that one for the details of each call; this one is about wiring them into Claude.
The short version
- Get an Instagram token and account id from a Meta app with Instagram Login.
- Save
server.mjs, a local MCP server with four tools: create a container, check it, publish it, read the daily limit. - Add it to
claude_desktop_config.jsonwith the token in itsenv, and restart Claude Desktop. - Ask Claude for a post. It drafts the caption, uploads the media as a container, and asks before the one tool that publishes.
Which Claude, which way
| You use | What it takes | Covered |
|---|---|---|
| Claude Desktop | A local MCP server, started by the app from claude_desktop_config.json |
This guide |
| Claude on the web, Cowork | A custom connector: a remote MCP server on the public internet | Below |
| Claude Code | A skill and a script, or an MCP server added with claude mcp add |
Claude Code guide |
| Any of them | A provider's connector, with your Instagram account linked to the provider | Their docs |
Local servers in claude_desktop_config.json are "a separate mechanism" from custom connectors, and they do not reach Cowork or Claude on the web (custom connectors).
Meta has its own MCP servers. In October 2026 they cover ads, WhatsApp Business, and managing the Meta apps you build (webhooks, app status, App Review), not publishing to Instagram (Meta MCP servers).
Before you start
- An Instagram professional account, business or creator. Meta's API only serves professional accounts.
- A Meta app with Instagram Login and a token with
instagram_business_basicandinstagram_business_content_publish. For your own account, the Generate token button in the App Dashboard gives a 60-day token, and no App Review is needed (get started, App Review). - The account's
user_idfromGET /me?fields=user_id,username, as in the Instagram API guide. - Claude Desktop and Node.js 20 or later.
- The image or video at a public URL. Meta fetches the file itself, so it must be on a publicly accessible server. A file in the chat or on your laptop will not work.
The server
Make a folder for it and install the MCP TypeScript SDK (v2) and zod:
mkdir ~/instagram-mcp && cd ~/instagram-mcp
npm init -y
npm install @modelcontextprotocol/server zodThen save this as server.mjs in that folder:
// A local MCP server with four Instagram tools, for Claude Desktop.
import { McpServer } from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import * as z from "zod/v4";
const API = "https://graph.instagram.com/v26.0";
const { IG_TOKEN, IG_ID } = process.env;
if (!IG_TOKEN || !IG_ID) {
console.error("Set IG_TOKEN and IG_ID in the server's env");
process.exit(1);
}
async function graph(method, path, body) {
const res = await fetch(`${API}/${path}`, {
method,
headers: { Authorization: `Bearer ${IG_TOKEN}`, "Content-Type": "application/json" },
body: body && JSON.stringify(body),
signal: AbortSignal.timeout(60_000),
});
const data = await res.json();
if (data.error) throw new Error(JSON.stringify(data.error));
return data;
}
const text = (value) => ({ content: [{ type: "text", text: JSON.stringify(value, null, 2) }] });
const container = (id) => graph("GET", `${id}?fields=status_code,status`);
serveStdio(() => {
const server = new McpServer({ name: "instagram", version: "1.0.0" });
server.registerTool(
"create_instagram_container",
{
description:
"Upload one image or reel to Instagram as an unpublished container. Nothing is posted. " +
"Show the user the caption before calling this.",
inputSchema: z.object({
kind: z.enum(["image", "reel"]),
media_url: z.url().describe("Public URL of a JPEG image, or an MP4 or MOV video for a reel"),
caption: z.string().max(2200),
}),
},
async ({ kind, media_url, caption }) => {
const body =
kind === "image"
? { image_url: media_url, caption }
: { media_type: "REELS", video_url: media_url, caption, share_to_feed: true };
const { id } = await graph("POST", `${IG_ID}/media`, body);
await new Promise((r) => setTimeout(r, 5_000));
return text({ container_id: id, ...(await container(id)) });
},
);
server.registerTool(
"get_instagram_container",
{
description:
"Read a container's status_code: IN_PROGRESS, FINISHED, PUBLISHED, ERROR or EXPIRED. " +
"Meta recommends checking at most once a minute, for no more than five minutes.",
inputSchema: z.object({ container_id: z.string() }),
annotations: { readOnlyHint: true },
},
async ({ container_id }) => text(await container(container_id)),
);
server.registerTool(
"publish_instagram_container",
{
description:
"Publish a FINISHED container to Instagram. The post goes live at once. " +
"Call it only after the user said yes to this post, and never twice for one container.",
inputSchema: z.object({ container_id: z.string() }),
},
async ({ container_id }) => {
const before = await container(container_id);
if (before.status_code === "PUBLISHED") return text({ already_published: true, ...before });
if (before.status_code !== "FINISHED") {
return { ...text({ not_ready: true, ...before }), isError: true };
}
try {
return text(await graph("POST", `${IG_ID}/media_publish`, { creation_id: container_id }));
} catch (error) {
// A publish that failed or timed out may still have gone out: report the container, never retry here.
return { ...text({ publish_error: error.message, container: await container(container_id) }), isError: true };
}
},
);
server.registerTool(
"get_instagram_publishing_limit",
{
description: "How many posts the account published through the API in the last 24 hours, and the limit.",
inputSchema: z.object({}),
annotations: { readOnlyHint: true },
},
async () => text(await graph("GET", `${IG_ID}/content_publishing_limit?fields=config,quota_usage`)),
);
return server;
});Why four tools and not one
A single post_to_instagram tool is shorter, and it is the wrong shape for a chat:
- Publishing is its own tool, so it is the one you approve. Creating a container posts nothing: containers that are never published expire after 24 hours (IG User Media).
- Waiting is Claude's job, not the server's. Every container is processed in the background, images included, and Meta recommends checking once a minute for no more than five minutes (content publishing). An image is usually
FINISHEDby the first check, 5 seconds in. A reel takes longer, so Claude checks again withget_instagram_containerinstead of one tool call hanging for minutes. - The publish tool never publishes twice. It reads the container first and stops if it is already
PUBLISHED. If the publish call fails or times out, it returns the container's status instead of retrying, because the post may have gone out. A success can also carry aconfig_issuesuch asCAPTION_NOT_ATTACHED: the post is live without its caption (media publish). - Meta's errors reach Claude as they are. A thrown error comes back as a tool result marked as an error, so Claude can read the code and subcode and tell you what they mean. Our error table lists the common ones.
The schema rejects what Meta would: a caption over 2,200 characters, a media URL that is not a URL. The SDK checks the arguments before the handler runs (tools), and Claude reads the message and tries again. Hashtag and mention limits (30 and 20) are left to Meta.
The server writes nothing to stdout itself: in a stdio server, stdout is the protocol channel, and logs go to stderr (stdio).
Add it to Claude Desktop
In Claude Desktop, open Settings, then Developer, then Edit Config. It opens claude_desktop_config.json, in ~/Library/Application Support/Claude/ on macOS and %APPDATA%\Claude\ on Windows (connect local servers). Add the server with absolute paths:
{
"mcpServers": {
"instagram": {
"command": "node",
"args": ["/Users/you/instagram-mcp/server.mjs"],
"env": {
"IG_TOKEN": "<INSTAGRAM_USER_ACCESS_TOKEN>",
"IG_ID": "<USER_ID>"
}
}
}
}Quit Claude Desktop completely and open it again. In a new chat, instagram should be listed among the tools Claude can use, with its four tools. If it is not, the server's own log is mcp-server-instagram.log in ~/Library/Logs/Claude on macOS or %APPDATA%\Claude\logs on Windows (same page). If node is not found, put the full path from which node in command.
The token sits in that file in plain text. A token with only the two permissions above can read the account's profile and publish to it, nothing else, and it lasts 60 days: refresh it before then (Business Login for Instagram), or every tool starts failing with code 190 (Graph API error handling).
Approve every publish
Claude Desktop asks for your approval before it runs a tool from a local server, and you can deny it (connect local servers). That prompt is the safeguard:
get_instagram_containerandget_instagram_publishing_limitonly read, so allowing them without asking each time is safe.create_instagram_containeruploads but publishes nothing, so allowing it is a matter of taste.- Approve
publish_instagram_containeronce per post, after reading the caption in the request. Never let it run without asking.
The tool descriptions also tell Claude to show you the caption first and to wait for your yes. The approval prompt is what makes sure of it, since a model can skip an instruction and cannot skip the prompt.
Post something
Attach nothing; give Claude a public URL:
Write an Instagram caption for https://example.com/photos/autumn-menu.jpg:
the new autumn menu, three drinks, warm tone, at most 5 hashtags.
Show it to me before you upload anything.What follows, in order: Claude shows the caption, you ask for changes or say yes, Claude asks to run create_instagram_container, then asks to run publish_instagram_container once the container is FINISHED, and replies with the post id. For a reel, Claude checks the container again until it is ready.
Before a batch, ask "how many posts can I still publish today?": get_instagram_publishing_limit reads Meta's own count. Meta's pages give different numbers for the limit, so the tool reads it instead of assuming one (content publishing limit). Our account returned 100 posts in 24 hours, and the count lagged by one post for a while (what we saw).
What it does not do
- Files from the chat. An image you attach in Claude is not at a public URL, and image containers only take an
image_urlthat Meta fetches (content publishing). Something has to host the file first. - Carousels and stories. Both use the same container flow: stories with
media_type: "STORIES", carousels with one container per item and a parent (our guide). Add a tool for each when you need them. - Scheduling. The server runs while Claude Desktop is open and only when you ask. A post for later needs something that calls
media_publishat that time.
Claude on the web needs a remote server
To post from Claude on the web or Cowork, the same tools have to live on a remote MCP server, added as a custom connector. Three things change (custom connectors):
- Claude connects from Anthropic's cloud, not from your device, so the server must be reachable from the public internet.
- The server needs its own sign-in. Connectors typically use OAuth, or a fixed credential sent in request headers. Without one, anyone who finds the URL can publish to your account.
- Plans: custom connectors work on every plan, and the Free plan is limited to one.
The SDK serves the same tools over HTTP (serving), but the hosting and the sign-in are now yours to run and keep safe. For one account, Claude Desktop with a local server is less to look after.
When a local server is not enough
The server is the right size for one person posting to their own account. It stops fitting when:
- You post to many networks. Each one is another server, another login and another set of rules.
- You post for other people. Their accounts need your app to pass Meta's App Review and Business Verification (App Review), plus a login flow and a token per account.
- Your media is not already public, or posts must go out while your computer is off.
Those are the cases where a hosted connector earns its place.
Where Rizamo fits
Rizamo is a social media API and MCP server built to take that layer off your hands: one call to publish, with Rizamo creating the container, waiting for it, publishing it and refreshing the tokens. In Claude it is added as a connector (Claude on Rizamo). Rizamo is opening soon: join the waitlist.