All posts
Engineering10 min read

How to build a social media MCP server

The MCP part takes an afternoon. Everything that makes it survive contact with seven social platforms takes considerably longer. Notes from building one, including the mistakes.

If you have looked at the MCP SDK and thought “this is not much code”, you are right. A stdio server with two working tools is under a hundred lines. The reason our social media MCP server is not a hundred lines is that none of the hard parts are protocol parts.

These are the notes we would have wanted before starting — the architecture that survived, and the four things we got wrong first.

The shape that works

One decision pays for itself repeatedly: the MCP server should be a thin translation layer, not a place where business logic lives.

  • A tool definitions module — pure JSON schemas, no behaviour.
  • A handlers module — one function per tool, each doing argument shaping and one backend call.
  • A client module — auth extraction and HTTP to the real backend.
  • A transport layer — stdio by default, Express-based streamable HTTP when a PORT is set, both wrapping the same server factory.

Everything genuinely difficult — token refresh, per-platform adapters, the scheduler, the credit ledger — stays behind the backend API. That way it is shared with the REST surface and the web app, and a bug gets fixed once rather than three times.

Write tool descriptions for a reader who cannot experiment

This is the part most people underestimate. Your tool descriptions are not documentation for humans who can try things and see. They are the entire context a model has when deciding whether to call the tool and what to pass.

Our first pass at create_post said, roughly, “creates a post”. Models used it constantly and wrongly: publishing when they meant to draft, targeting whole platforms when they meant one profile, omitting timezone. None of that was the model being careless. The schema simply did not say.

What fixed it was writing descriptions that state consequences:

  • What it does, including side effects. “Each targeted profile becomes its own post with its own id” is behaviour a caller must know.
  • What it costs. Credits, tokens, rate limit. A model that knows an action is expensive batches better.
  • What happens on failure. “Skips profiles that already received it” turns a retry from a gamble into a decision.
  • The distinction between similar parameters. targetAccounts sends only to named profiles; platforms fans out to every connected profile on that platform. One sentence prevents an entire class of accidental broadcast.

Give the model a way to be wrong safely

The single highest-value tool on our server is the one that does nothing. preflight_post accepts exactly what create_post accepts, runs every validation, and publishes nothing. It returns limit overflows per network, unconnected profiles, missing media and the exact credit cost.

Before it existed, the loop was: model writes a 310-character post, calls create, X rejects it, model apologises, rewrites, retries. Occasionally it retried against a network that had already accepted the post. After it existed, the model discovered the overflow in a call with no consequences and fixed it silently.

Every mutating tool wants a rehearsal. Models are much better at correcting themselves than at not making the mistake.

Idempotency, because retries are not optional

Agents retry. They retry after timeouts, after ambiguous errors, and after a user says “try again” without knowing the first attempt half-succeeded. Design as though every mutating call may arrive twice.

Two things made this tractable for us:

  1. 1One post record per profile. Posting to LinkedIn and X creates two independent records. Retrying the failed one cannot double-post the delivered one — the state is per profile, so there is nothing to get wrong.
  2. 2An explicit unstick path. reset_stuck_post releases a post caught mid-publish, and profiles already delivered keep their state. Without an escape hatch, agents invent one, usually by deleting and recreating.

Batching, and validating before you execute

A week of content across four networks is thirty-odd tool calls. Thirty approval prompts is a user experience nobody accepts, so they turn approval off entirely — which is worse.

A multicall tool taking up to twenty operations solves this, but only if it validates the whole batch before executing any of it. Validate every tool name up front, then run in order and return a per-call result with a stopOnError flag. A typo in call nineteen must not leave eighteen posts written and an agent with no clear picture of what happened.

// Reply shape that agents handle well:
{
  "results": [
    { "id": "img", "tool": "generate_image", "ok": true,  "result": { ... } },
    { "tool": "create_post",                 "ok": false, "error": "..." }
  ],
  "counts":  { "ok": 1, "failed": 1, "skipped": 3 },
  "skipped": [ /* the calls stopOnError prevented */ ]
}

Two transports, one binary

Gate the mode on an environment variable and keep the handlers shared:

// stdio — what Claude Desktop and Cursor spawn
npx -y @postmcpai/server

// streamable HTTP — what Claude.ai connects to
PORT=3000 npx -y @postmcpai/server

The HTTP mode needs more than a port. Remote clients expect OAuth 2.0 authorization-server metadata and RFC 9728 discovery to register themselves, and if you also want ChatGPT Custom GPT Actions you need an OpenAPI 3.0 document and plain REST endpoints alongside the MCP route. All of that is additive plumbing around the same handler functions — worth doing once, in one place.

Four things we got wrong

  1. 1
    Too many tools, too finely sliced

    An early version had separate tools for draft, schedule and publish. Models chose wrongly between them constantly, because the distinction was in our heads. Collapsing them into one create_post with a mode parameter cut errors sharply. Fewer tools with clearer parameters beats more tools with subtle differences.

  2. 2
    Returning raw backend JSON

    Dumping a full API response burns context and buries the answer. Return what a caller needs to decide the next step. list_posts returns the queue with per-profile status, not every field the database holds.

  3. 3
    Swallowing platform errors

    We normalised platform failures into a generic message. Agents then guessed, badly. Surfacing the platform’s own error per profile let them read “duplicate content” and actually fix it.

  4. 4
    Assuming UTC was a safe default

    It is a defensible default and a terrible assumption. A 9:00 post scheduled without a zone goes out at 14:30 in India. We now name timezone prominently in the schema description and say what happens without it. Documentation as a bug fix.

Before you build one

Worth asking honestly: is the value in your protocol layer or in your domain? If you are exposing your own product’s logic to AI clients, build it — the protocol is easy and nobody else can do it for you.

If the goal is “let my agent post to social networks”, the protocol was never the hard part. Multi-platform OAuth refresh, media rules and retry semantics are weeks of work with a long tail of platform-specific surprises. Ours is MIT licensed on npm — fork it, or read it for the parts you want.

Frequently asked questions

How hard is it to build an MCP server?
The protocol layer is genuinely easy — the official SDK gets a working stdio server with a couple of tools running in well under an hour. The difficulty is entirely in the domain: for social publishing that means OAuth token refresh across platforms, per-network content limits, media handling, idempotent retries and error surfaces a model can act on.
What language should I write an MCP server in?
Whatever your domain logic is already in. Official SDKs exist for TypeScript/JavaScript, Python and several others, and the protocol is transport-agnostic. Node is a pragmatic default for a server you want people to run with npx, because that removes the install step entirely.
How should I design MCP tool schemas?
Write descriptions for a reader who cannot experiment. Say what the tool does, what happens on failure, and what it costs. Keep required parameters minimal, use enums instead of free strings wherever the value set is closed, and add a dry-run tool so the model can discover problems without consequences.
Should I support both stdio and HTTP transports?
Yes, and from one binary. stdio-only cannot serve web clients; HTTP-only forces users to deploy something before they can try anything. Gating the mode on an environment variable such as PORT keeps the codepath shared so local testing exercises the same handlers as production.

Give your AI assistant publishing tools

One npx command connects Claude, ChatGPT or Cursor to seven social networks. 20 free posts a month, no card.