---
title: "Post to Instagram from Claude: a local MCP server over Meta's API"
description: "Give Claude Desktop four Instagram tools with a 100-line MCP server on Meta's official API. Claude drafts, you approve each publish. No third-party account."
url: https://rizamo.com/guides/post-to-instagram-from-claude/
---

# Post to Instagram from Claude: a local MCP server over Meta's API

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](/guides/how-to-post-to-instagram-via-api/), 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

1. Get an Instagram token and account id from a Meta app with Instagram Login.
2. Save `server.mjs`, a local MCP server with four tools: create a container, check it, publish it, read the daily limit.
3. Add it to `claude_desktop_config.json` with the token in its `env`, and restart Claude Desktop.
4. 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-on-the-web-needs-a-remote-server) |
| Claude Code | A skill and a script, or an MCP server added with `claude mcp add` | [Claude Code guide](/guides/claude-code-social-media-automation/) |
| 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](https://support.claude.com/en/articles/11175166-getting-started-with-custom-connectors-using-remote-mcp)).

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](https://developers.facebook.com/documentation/mcp)).

## Before you start

- An Instagram professional account, business or creator. Meta's API [only serves professional accounts](https://developers.facebook.com/documentation/instagram-platform/overview).
- A Meta app with Instagram Login and a token with `instagram_business_basic` and `instagram_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](https://developers.facebook.com/documentation/instagram-platform/instagram-api-with-instagram-login/get-started), [App Review](https://developers.facebook.com/documentation/instagram-platform/app-review)).
- The account's `user_id` from `GET /me?fields=user_id,username`, as in the [Instagram API guide](/guides/how-to-post-to-instagram-via-api/#post-an-image-with-curl).
- [Claude Desktop](https://claude.ai/download) 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](https://developers.facebook.com/documentation/instagram-platform/content-publishing). 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](https://ts.sdk.modelcontextprotocol.io/v2/) (v2) and zod:

```bash title="1. Set up the folder"
mkdir ~/instagram-mcp && cd ~/instagram-mcp
npm init -y
npm install @modelcontextprotocol/server zod
```

Then save this as `server.mjs` in that folder:

```js title="~/instagram-mcp/server.mjs"
// 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](https://developers.facebook.com/documentation/instagram-platform/instagram-graph-api/reference/ig-user/media#limitations)).
- **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](https://developers.facebook.com/documentation/instagram-platform/content-publishing)). An image is usually `FINISHED` by the first check, 5 seconds in. A reel takes longer, so Claude checks again with `get_instagram_container` instead 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 a `config_issue` such as `CAPTION_NOT_ATTACHED`: the post is live without its caption ([media publish](https://developers.facebook.com/documentation/instagram-platform/instagram-graph-api/reference/ig-user/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](/guides/how-to-post-to-instagram-via-api/#common-errors) 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](https://ts.sdk.modelcontextprotocol.io/v2/servers/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](https://ts.sdk.modelcontextprotocol.io/v2/serving/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](https://modelcontextprotocol.io/docs/develop/connect-local-servers)). Add the server with absolute paths:

```json title="claude_desktop_config.json"
{
  "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](https://developers.facebook.com/documentation/instagram-platform/instagram-api-with-instagram-login/business-login)), or every tool starts failing with code `190` ([Graph API error handling](https://developers.facebook.com/docs/graph-api/guides/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](https://modelcontextprotocol.io/docs/develop/connect-local-servers)). That prompt is the safeguard:

- `get_instagram_container` and `get_instagram_publishing_limit` only read, so allowing them without asking each time is safe.
- `create_instagram_container` uploads but publishes nothing, so allowing it is a matter of taste.
- Approve `publish_instagram_container` once 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:

```text title="In Claude Desktop"
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](https://developers.facebook.com/documentation/instagram-platform/instagram-graph-api/reference/ig-user/content_publishing_limit)). Our account returned 100 posts in 24 hours, and the count lagged by one post for a while ([what we saw](/guides/how-to-post-to-instagram-via-api/#the-daily-publishing-limit)).

## 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_url` that Meta fetches ([content publishing](https://developers.facebook.com/documentation/instagram-platform/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](/guides/how-to-post-to-instagram-via-api/#post-a-carousel)). 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_publish` at 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](https://support.claude.com/en/articles/11175166-getting-started-with-custom-connectors-using-remote-mcp)):

- **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](https://ts.sdk.modelcontextprotocol.io/v2/serving/http)), 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](https://developers.facebook.com/documentation/instagram-platform/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](/agents/claude/)). Rizamo is opening soon: [join the waitlist](/#waitlist).
