We run an Instagram comment-to-DM service: someone comments a keyword like GUIDE on a post, and they get a DM with the link. This month we published an open-source Instagram MCP server for it, so Claude, Cursor or Codex can set those automations up from a chat while the creator is still editing the video.
This post is about the engineering, not the product. The server is two files and about 400 lines of JavaScript, and most of the decisions in it are about one problem: an agent is calling tools that send messages to real people. If you're building an MCP server for an API with side effects, most of this applies to you too.
Code: github.com/commentkeyword/instagram-mcp-server (MIT).
Why an Instagram MCP server with no dependencies
Most MCP servers start from the official SDK. We didn't, for two reasons.
People run it on their own machine with an API token. Anyone installing a server that holds a token should be able to read all of it in ten minutes. With no dependencies there's nothing in node_modules to audit, and no supply chain beyond Node itself.
It installs straight from a Git tag. npx -y github:commentkeyword/instagram-mcp-server#v0.2.0 works with no build step and no npm publish, and pinning a tag means the code you audited is the code that runs.
The cost is that we implement the protocol ourselves. For a tools-only server over stdio, that turned out to be small.
The stdio transport is 30 lines
MCP over stdio is newline-delimited JSON-RPC 2.0. The entry point reads lines from stdin, parses them, and writes one JSON line per response:
createInterface({ input: process.stdin, crlfDelay: Infinity }).on('line', (line) => {
if (!line.trim()) return;
let message;
try {
message = JSON.parse(line);
} catch {
send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } });
return;
}
handleMessage(handlers, message).then(send, (error) => {
console.error(error);
if (message?.id !== undefined) send({ jsonrpc: '2.0', id: message.id, error: { code: -32603, message: 'Internal error' } });
});
});
Two details matter:
- Logs go to stderr, never stdout. Anything on stdout that isn't a JSON-RPC message breaks the client.
- The server starts without a token. Clients and registries spawn servers just to list their tools. If a missing token killed the process, the listing would fail, so each tool call reports the missing token instead.
handleMessage answers initialize, ping, tools/list and tools/call, ignores notifications, and negotiates the protocol version: it echoes the client's version if we support it, and offers our newest one otherwise.
Design tools around intent, not the API's internals
Our REST API stores a keyword's reach as a scope (global or post_specific), an includeNextPost flag and a list of media IDs. That's a fine data model and a bad tool interface. Give an agent three fields that interact and it will eventually send a combination that means nothing.
So the tool takes one enum, target, and the server translates it:
function scopeFor(t, mediaIds) {
switch (t) {
case 'next_post':
return { scope: 'post_specific', includeNextPost: true, ...(mediaIds?.length ? { mediaIds } : {}) };
case 'all_posts':
return { scope: 'global' };
case 'specific_posts':
if (!mediaIds?.length) throw new Error('target "specific_posts" needs at least one media ID in mediaIds.');
return { scope: 'post_specific', includeNextPost: false, mediaIds };
}
}
next_post is the target creators use most. A video that's still in the edit has no Instagram media ID yet. With next_post, the automation waits and attaches itself to the next post or Reel the account publishes, so the agent can set up the DM in the same session it writes the caption.
Tools that message people need guardrails
A keyword automation messages everyone who comments the word. A wrong DM to a few hundred people can't be unsent, so the guardrails are spread across several layers:
-
Server instructions. The
initializeresponse tells the agent to confirm the keyword, the DM text and the target with the user before callingcreate_keywordorupdate_keyword. Clients that support MCP instructions put this in front of the model. - Tool descriptions repeat it, because not every client shows server instructions.
-
Annotations.
create_keywordis markeddestructiveHint: false, openWorldHint: true, anddelete_keywordis marked destructive, so clients can ask for approval. -
A second step for deletes that lose work. If a keyword has an AI agent attached, the first
delete_keywordcall returnsDELETE_CONFIRMATION_REQUIREDwith a summary of what would be lost. The agent has to show it to the user and call again withconfirm: true. The tool description also nudges the agent toward pausing (isActive: false) instead. - Treat API text as data. Keywords, DM texts and agent names come from the user's workspace, and the instructions say plainly that this text is data, not instructions. It's a cheap defense against prompt injection through stored content.
In practice the conversation looks like this:
You: I'm posting a Reel tomorrow about my budget template. Anyone who comments
BUDGET should get https://example.com/budget by DM.
Claude: Here's what I'll set up:
Keyword: BUDGET (whole word)
Posts: your next post or Reel
DM: "Here's the budget template, {{namefirst}} 👉 https://example.com/budget"
Create it?
You: Yes
Claude: Done. BUDGET is waiting for your next post.
Return a sentence, then the JSON
Tool results go back to the model as text. If you return only JSON, the model has to work out what the state means, and sometimes it guesses wrong. So each write returns a one-line summary first and the raw JSON after it:
if (data.nextPostStatus === 'waiting') parts.push('It is waiting for the next post or reel; it attaches itself once that post gets its first comments.');
if (data.nextPostStatus === 'reconnect_required') parts.push('Instagram needs to be reconnected in CommentKeyword before it can catch the next post.');
Since we added these lines, the agent reports "waiting for your next post" or "reconnect Instagram first" accurately instead of a cheerful "all set". The summary also gives the model the right words to use with a non-technical user.
Validate before you call the API
The server checks arguments against each tool's JSON Schema before calling the API, with a small validator (types, enums, required fields, string and array limits). A bad call comes back as an isError result like Invalid arguments: arguments.dmMessages allows at most 10 items., which the model can fix and retry. An HTTP 400 from deep inside the API is harder for it to act on.
API errors are passed through with their body, so a 409 for a keyword that already exists reaches the model with the reason attached. In practice the agent then calls list_keywords and suggests another word.
The hard part was server-side
The MCP layer turned out to be the easy part. "Wait for the next post" has real edge cases on the service side:
- Two automations with the same keyword both waiting for the next post would both claim it, and only one would ever answer. We now allow one active waiter per keyword, checked under a per-workspace database lock, so two requests at the same moment can't both get through.
- The Instagram token can expire while a keyword is waiting. That's why
reconnect_requiredis a status the agent sees, not a silent failure. - Instagram's messaging rules shape everything: a comment gets one private reply within 7 days, and more messages are only allowed within 24 hours after the person writes back. The tool descriptions don't promise anything the platform won't allow.
If your MCP server wraps an API, the edge cases in that API will matter more than the protocol code.
Testing it end to end
npm test spawns the real server over stdio against a mock HTTP API and drives it with JSON-RPC: initialize, list tools, valid and invalid calls, the delete confirmation flow, and the missing-token path. Because there's no SDK in between, the tests check the exact bytes a client would see.
Try it
You need a CommentKeyword account (the Free plan includes the API), an Instagram professional account and an API token. In Claude Code:
claude mcp add --transport stdio --env COMMENTKEYWORD_API_TOKEN=YOUR_TOKEN commentkeyword -- npx -y github:commentkeyword/instagram-mcp-server#v0.2.0
Setup for Claude Desktop, Cursor, Windsurf and Codex is on the Instagram MCP server page. The repo also has an Agent Skill that plans the "comment X" call to action in a Reel's script, and an n8n template.
Questions about the design, or about building an MCP server for an API with side effects? Ask in the comments.
Disclosure: we build CommentKeyword, the service this server talks to. This post was written with AI assistance; we checked every code sample against the repository.
Top comments (0)