DEV Community

Cover image for I built an MCP server for X (Twitter) — 14 tools in ~600 lines, here's what I learned
Omer Faruk Kolip
Omer Faruk Kolip

Posted on

I built an MCP server for X (Twitter) — 14 tools in ~600 lines, here's what I learned

I ship a free X (Twitter) creator toolkit at xtapdown.com — 17 web tools for downloading tweets, generating hashtags, calculating engagement, splitting threads, that sort of thing. A few weeks ago I wrapped 14 of them as an MCP server and published it on npm, the Anthropic MCP Registry, Glama, and got it merged into awesome-mcp-servers.

The whole thing is ~600 lines of TypeScript. Here's what I learned along the way, and everything that actually mattered vs. the stuff I over-thought.


Why MCP over just having a REST API

I already had a REST API powering the web tools. I could have documented it and said "call these endpoints." Nobody would have.

The problem with a REST API for AI use cases: the LLM has to know your API exists. If nobody adds xtapdown.com to their system prompt or context, it might as well not exist. MCP inverts that — the client discovers your tools once, and from then on, whenever the LLM decides "I need to download a tweet," your tool is right there in its tool list.

I built the web tools first, spent months writing content around them, and got approximately zero traffic from AI assistants. Two weeks after publishing the MCP server, it had 500+ npm downloads. Different distribution channel, different mechanics.

TL;DR: MCP is a distribution primitive for tools that AI assistants should call directly. If your product's core use case fits into a single LLM turn ("download this tweet", "give me hashtags for AI"), MCP is a much better fit than a REST API you have to onboard people onto.


The stack

Nothing exotic:

  • TypeScript + @modelcontextprotocol/sdk
  • stdio transport for local Claude Desktop / Cursor / Cline use
  • Streamable HTTP transport for remote clients
  • npm as the primary distribution channel (npx -y xtapdown-mcp runs it with zero setup)

The tool handlers are pure functions. No database, no session state, no auth (the underlying data endpoints are public). This keeps the whole thing small enough that anyone can read the source and audit it before running it.

// One tool handler, roughly:
server.tool(
  'download_tweet',
  {
    url: z.string().url().describe('X/Twitter post URL'),
    include_media: z.boolean().optional(),
  },
  async ({ url, include_media }) => {
    const tweet = await fetchTweet(url);
    return {
      content: [{ type: 'text', text: formatTweet(tweet, include_media) }],
    };
  },
);
Enter fullscreen mode Exit fullscreen mode

Fourteen of these, each 20-40 lines. That's the whole MCP surface area.


The syndication endpoint trick

The single most useful thing I want to share, because it took me a day of digging to find:

X has a public JSON endpoint that returns full tweet data, no auth, no rate limits I've hit. It's the endpoint the embed widget uses. It's been stable for years. You get:

  • Full text with entities
  • All attached media (video with variant URLs, images with source resolutions, GIFs as MP4)
  • Author info
  • Engagement stats (likes, retweets, replies, bookmarks, quotes)
  • Reply-to metadata for thread walking

I use it for download_tweet, get_tweet_screenshot_url, and thread reconstruction. In three months of production use across two sites, I've seen exactly one rate limit — during a viral link storm from a big Reddit thread.

Search for cdn.syndication.twimg.com/tweet-result. That's your endpoint. Handle the CORS on your own server, cache the response, done.

Caveat: this is a public endpoint but it's not a documented API. It could change. Have a fallback plan (or at least monitoring) if you build something serious on top of it.


Publishing — this is where MCP gets weird

Building the server is 10% of the effort. Getting it discoverable is 90%. There are five separate channels and each has its own quirks:

1. npm

Straightforward. Publish with npm publish --access public. The package name matters — mine is xtapdown-mcp which matches the site (xtapdown.com). Symmetry helps discoverability.

Add to package.json:

{
  "mcpName": "io.github.farukkolip/xtapdown-mcp"
}
Enter fullscreen mode Exit fullscreen mode

This is the canonical ID the Anthropic MCP Registry uses. Reverse-DNS style, tied to your GitHub org/user. Get this right on first publish because it's a pain to change.

2. Anthropic MCP Registry

Official registry from Anthropic. Uses a Go CLI called mcp-publisher (get it from the modelcontextprotocol/registry releases, not npm — I lost 20 minutes to that).

You need a server.json in your repo:

{
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
  "name": "io.github.farukkolip/xtapdown-mcp",
  "description": "14 X (Twitter) creator tools as an MCP server",
  "repository": {
    "url": "https://github.com/farukkolip/xtapdown-mcp",
    "source": "github"
  },
  "packages": [
    {
      "registryName": "npm",
      "name": "xtapdown-mcp",
      "version": "..."
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Then:

mcp-publisher login github
mcp-publisher publish
Enter fullscreen mode Exit fullscreen mode

Validation is strict. Description over 100 chars → rejected. Schema URL wrong → rejected. Repository URL missing .source → rejected. Get all three right the first time.

3. Glama

Glama auto-discovers MCP servers from GitHub and builds a Docker image to test them. If the build fails, they mark the badge red and the score is null.

Two gotchas:

  • They auto-detect pnpm if pnpm-lock.yaml exists even if your project uses npm. Add a glama.json at repo root overriding the build:
  {
    "build": {
      "commands": ["npm ci", "npm run build"]
    }
  }
Enter fullscreen mode Exit fullscreen mode
  • The badge requires a LICENSE file. Not just "license": "MIT" in package.json — the actual LICENSE file at repo root. Add one and their scanner picks it up on the next refresh.

Got both right → A/A/A score, verified badge.

4. awesome-mcp-servers

The punkpeye/awesome-mcp-servers repo. One-line PR adding your server in the alphabetically correct spot in the right section. Include the Glama badge (they require it now via a GitHub Actions bot).

My PR got merged in a couple of days. Merge rate for real projects with good descriptions is high — the maintainer is friendly.

5. PulseMCP + mcp.so + others

These are third-party directories. Most of them auto-sync from the Anthropic Registry, so publishing there automatically populates the rest. Only PulseMCP needed a manual form for me.

Skip Smithery. I know it's on every "how to publish MCP" guide, but the current publishing flow is awkward and the audience-per-effort ratio is bad compared to the other five channels.


Testing this thing

For local dev, add to claude_desktop_config.json:

{
  "mcpServers": {
    "xtapdown": {
      "command": "npx",
      "args": ["-y", "xtapdown-mcp"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Restart Claude Desktop. In a new chat, ask:

"Download this tweet for me: https://x.com/AnthropicAI/status/..."

If everything works, Claude will call download_tweet and hand you back the media URLs and the tweet text.

For CI, the MCP SDK ships a runInMemory test harness. Roughly:

import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';

const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
const client = new Client({ name: 'test', version: '0.0.1' }, { capabilities: {} });
await client.connect(clientTransport);

const result = await client.callTool({
  name: 'download_tweet',
  arguments: { url: 'https://x.com/AnthropicAI/status/1234...' },
});
Enter fullscreen mode Exit fullscreen mode

Faster than spinning up a real client, catches most breakage.


What I'd do differently

Do this first, not last: decide your mcpName (the reverse-DNS ID) before you publish anywhere. It has to match across npm, Anthropic Registry, and Glama. Changing it later means republishing under a new identifier and losing whatever discoverability the old one built.

Don't overthink tool naming. I spent hours agonizing over download_tweet vs get_tweet vs fetch_tweet_media. In practice the LLM picks the right tool from the description regardless of the name, as long as the name isn't actively misleading.

Ship the CI test harness on day one. I skipped it initially, then had to add it after a silent regression broke get_x_trends for a week. Nobody complained because nobody was watching, but I noticed a drop in npm downloads and traced it back.

Distribution beats tool count. I have 14 tools. Half of them are probably never called. But 14 sounds better in the readme than 6, and once someone's installed the server, adding a rarely-used tool costs nothing.


What's next

I'm building the same pattern for TikTok (tiktapdown.comtiktapdown-mcp) and Instagram is queued. The goal is a small family of per-platform MCP servers that share nothing but a common publishing pipeline and Docker base image.

If you're building your own MCP server, the fastest way to sanity-check the design is to install one that already works (mine or someone else's) and see what the tool calls actually look like from the LLM side. That gave me the biggest "oh, so that's how this fits together" moment early on.

Try it

Happy to answer any technical questions in the comments — especially about the syndication endpoint approach or any of the distribution channels above. If you're stuck on a publishing quirk I ran into, I probably have the fix in a scratch file somewhere.

Top comments (1)

Collapse
 
mads_hansen_27b33ebfee4c9 profile image
Mads Hansen

This is a useful breakdown because the hard part of social-platform MCP servers is rarely the tool wrapper itself. It is the boundary around what the tool is allowed to do.

For something like X/Twitter, I would want the server design to make these things explicit:

  • read-only tools vs write tools
  • account context for every call
  • rate-limit and quota behavior
  • dry-run mode for posting actions
  • audit logs for generated text and final payloads
  • approval gates before anything public happens
  • clear handling of deleted or unavailable content

That keeps the MCP server from becoming “an LLM with a social media account.” The tool surface should make the safe path obvious and the risky path deliberate.