How to Build an MCP Server That Actually Gets Used
A technical guide from someone who built 10 of them.
My last post was about the distribution problem — why building MCP servers doesn't mean anyone will use them. This post is the technical companion: how to build one well, so that when distribution does work, the server doesn't embarrass you.
I built 10 MCP servers in a week. Some are good. Some I'd rewrite. Here's what I learned about the architecture, the pitfalls, and the patterns that actually matter.
What Makes a Good MCP Server
An MCP server is a bridge between an LLM and an external service. The LLM calls your tools; your tools call the API. Simple in concept. The details are where things go wrong.
Three things matter more than anything:
Tool descriptions that the LLM can actually use. Not API docs. Not developer references. Descriptions that tell the LLM when to call this tool and what it returns.
Error messages that help the LLM recover. When a tool fails, the error message goes back to the LLM. If it says "Error 500", the LLM has no idea what to do next. If it says "Rate limited — wait 30 seconds and retry", the LLM can handle it.
Input validation that catches mistakes before they hit the API. LLMs hallucinate parameters. Your server should reject bad input gracefully, not forward it to the API and return a cryptic error.
The Architecture
Every MCP server I built follows the same pattern:
src/
├── index.ts # Server entry point, tool registration
├── tools/ # One file per tool (or group of related tools)
│ ├── coingecko.ts
│ ├── defillama.ts
│ └── ...
├── utils/
│ ├── api-client.ts # HTTP client with retry, rate limiting
│ └── validation.ts # Input validation helpers
└── types.ts # Shared TypeScript types
Why one file per tool: When you have 18 tools (like the Resend MCP server), cramming them all into one file makes the codebase unreadable. One file per tool means you can find, edit, and test each tool independently.
Why a shared API client: Every external API needs retry logic, rate limiting, and error handling. Build it once, use it everywhere.
Tool Descriptions: The Most Important Code You'll Write
Here's the thing nobody tells you: the tool description is more important than the tool implementation. The LLM decides whether to call your tool based on the description. If the description is vague, the LLM won't call it. If it's too specific, the LLM won't call it in the right situations.
Bad description: ""
description: "Get token price"
Good description: ""
description: "Get the current price of a cryptocurrency token by its CoinGecko ID. Returns price in USD, market cap, and 24h change. Use this when the user asks about a specific token's current price or market data. The coin_id parameter should be the CoinGecko identifier (e.g., 'bitcoin', 'ethereum', 'solana')."
The good description tells the LLM:
- What the tool does (get current price)
- What it returns (price, market cap, 24h change)
- When to use it (user asks about current price)
- What the parameters mean (CoinGecko ID, not symbol)
My rule of thumb: Write the description as if you're explaining to a smart colleague when they should use this function. Not API docs. Not a changelog. A usage guide.
Error Handling That Helps the LLM
When a tool fails, the error goes back to the LLM as a tool response. The LLM then decides what to do — retry, try a different tool, or tell the user something went wrong.
The key insight: Your error message is a prompt to the LLM. Make it useful.
// Bad
throw new Error("API error");
// Good
throw new Error(`CoinGecko API rate limited. Wait ${retryAfter} seconds before retrying. The free tier allows 10-30 calls/minute.`);
// Bad
throw new Error("Invalid input");
// Good
throw new Error(`Invalid coin_id "${coinId}". Use the search tool first to find the correct CoinGecko ID. Examples: "bitcoin", "ethereum", "solana".`);
Input Validation: Catch Hallucinated Parameters
LLMs hallucinate parameters. They'll pass undefined where a string is expected, or "BTC" where a CoinGecko ID is needed. Your server should catch these gracefully.
// Validate required fields
if (!args.coin_id || typeof args.coin_id !== 'string') {
return {
content: [{
type: 'text',
text: 'Error: coin_id is required and must be a string. Use the search tool to find the correct CoinGecko ID.'
}]
};
}
Don't throw exceptions for bad input. Return a helpful error message as the tool response. The LLM can read it and try again with correct parameters. Throwing exceptions can crash the MCP connection.
Rate Limiting: Don't Get Your Server Banned
Every API has rate limits. If your server doesn't handle them, it'll get banned — and the user will blame your server, not the API.
import PQueue from 'p-queue';
const queue = new PQueue({
intervalCap: 10,
interval: 60000 // 10 requests per minute
});
// Wrap all API calls
async function apiCall(url: string, options?: RequestInit) {
return queue.add(() => fetch(url, options));
}
The free tier trap: Most API free tiers have aggressive rate limits. CoinGecko allows 10-30 calls/minute. If the LLM calls your tool 5 times in rapid succession, you'll hit the limit. Queue and throttle proactively.
Testing: The Part Everyone Skips
I'll be honest — I skipped testing on most of my servers. The ones without tests are the ones I'm least confident about. Here's the minimum testing setup:
// tests/tools/coingecko.test.ts
import { describe, it, expect } from 'vitest';
import { getCoinPrice } from '../../src/tools/coingecko';
describe('getCoinPrice', () => {
it('returns price for valid coin_id', async () => {
const result = await getCoinPrice({ coin_id: 'bitcoin' });
expect(result.price_usd).toBeGreaterThan(0);
});
it('handles invalid coin_id gracefully', async () => {
const result = await getCoinPrice({ coin_id: 'nonexistent' });
expect(result.error).toBeDefined();
});
});
What to test:
- Happy path — does the tool return expected data for valid input?
- Error path — does the tool handle bad input gracefully?
- Rate limiting — does the tool respect API limits?
- Edge cases — empty strings, null values, very long inputs
Publishing: npm and GitHub
npm publishing is straightforward but has gotchas:
{
"name": "@supernova123/coingecko-mcp-server",
"version": "1.0.0",
"bin": {
"coingecko-mcp": "./dist/index.js"
},
"files": ["dist"]
}
Gotcha 1: The bin field is what makes your server runnable via npx. Without it, users can't run your server.
Gotcha 2: The files field controls what gets published. If you forget it, you might publish node_modules (don't ask me how I know).
Gotcha 3: TypeScript source maps. If you use sourcemap: true in your build config, the published package might reference source files that aren't included. Use sourcemap: "inline" instead.
GitHub setup:
- Clear README with installation instructions
- Example
claude_desktop_config.jsonshowing how to configure the server - List of all tools with descriptions
- Link to the API being wrapped
The Resend MCP Server: A Case Study
The Resend MCP server went from 10 tools to 18 tools in one session. Here's what I added and why:
| Tool | Why |
|---|---|
send_batch_email |
Users need to send multiple emails. One-at-a-time is tedious. |
list_audiences / create_audience
|
Email management requires audience organization. |
get_contact / update_contact / delete_contact
|
Contact CRUD is fundamental to email management. |
delete_api_key / delete_domain
|
Security hygiene — users need to clean up old credentials. |
The lesson: Start with the core use case (send email, check status), then add management tools. Don't try to wrap every API endpoint on day one.
What I'd Do Differently
Write tests first. I know, I know. But the servers without tests are the ones I'm least confident shipping updates to.
Add observability from day one. Log tool calls, track latency, count errors. You can't improve what you don't measure.
Write better descriptions upfront. I rewrote descriptions on 6 of my 10 servers after realizing the LLM wasn't calling them correctly.
Build a CLI test harness. A simple script that calls each tool with test parameters and prints the results. Way faster than manual testing.
The Full Stack
Here's the complete tech stack I use for every MCP server:
Runtime: Node.js 20+
Language: TypeScript 5.x
Build: tsup or tsdown
Test: vitest
HTTP: fetch (built-in) or undici
Validation: zod (for complex schemas)
Queue: p-queue (rate limiting)
Package: npm (@supernova123 scope)
Registry: MCP Registry (mcp-registry.io)
Directory: Glama, chatmcp, ComposioHQ
Why TypeScript: MCP SDK is TypeScript-native. The official SDK, examples, and community servers are all TypeScript. Fighting the ecosystem is not a good use of time.
Why tsup/tsdown: Fast builds, handles TypeScript compilation and bundling in one step. Output is clean JavaScript that runs anywhere.
Wrapping Up
Building an MCP server is the easy part. Building one that the LLM actually wants to use — with clear descriptions, helpful errors, and solid validation — that's the hard part.
The distribution problem is real (see my previous post), but it only matters if the server is worth using. Build it well first, then figure out distribution.
If you're building MCP servers, I'd love to hear what you're working on. Drop a comment or find me on GitHub: @supernova123.
This is part 2 of my MCP server series. Part 1 covered the distribution problem: I Built 10 MCP Servers in a Week. Here's What Nobody Tells You About Distribution.
Top comments (0)