Building an MCP server is easier than most people expect. The official SDKs handle the protocol, so a working server with one tool is about twenty lines. The hard part isn’t the code, it’s designing tools a model can actually use well.
New to the concept? What is an MCP server covers it first.
The minimal server
Using the TypeScript SDK, a complete server with one tool:
import { McpServer } from '@modelcontextprotocol/server';import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';import * as z from 'zod/v4';
const server = new McpServer({ name: 'greeting-server', version: '1.0.0' });
server.registerTool( 'greet', { description: 'Greet someone by name', inputSchema: z.object({ name: z.string() }), }, async ({ name }) => ({ content: [{ type: 'text', text: `Hello, ${name}!` }], }),);
const transport = new StdioServerTransport();await server.connect(transport);That’s a real server. Point a client at it and the model can call greet.
There are official SDKs for TypeScript, Python, and several other languages, and the shape is the same in each: create a server, register tools with a name, a description, and an input schema, connect a transport.
Test it before wiring it into a client. The MCP Inspector connects to any server and shows you exactly which tools it exposes and what they return. That distinction, is the server broken or is the client misconfigured, is worth seconds instead of an hour.
The part that actually matters
Everything above is mechanical. What separates a good server from a bad one is tool design, and it’s genuinely different from API design.
An API is read by a programmer with documentation. A tool is chosen by a model at runtime from a one-line description. That changes what “good” means.
Descriptions are the interface
The description is not documentation, it’s the selection mechanism. A model reads it and decides whether this tool fits the request.
Bad: "Updates a record."Good: "Update an existing post's title, content, status, or terms. Use get-post first to see current values. Does not create posts."The second says what it does, when to use it, and what it doesn’t do. That last part prevents a whole class of wrong calls.
Write descriptions for a capable colleague who has never seen your system and will read exactly one sentence about it.
Fewer tools, better chosen
Every tool definition costs context on every request, before the user’s actual question. Twenty vague tools are worse than eight clear ones, and not only because of tokens: a long list of similar-sounding tools makes the model’s choice harder, and wrong choices compound.
Some clients also refuse to load servers past a cap. Antigravity stops at 100 tools, silently.
Resist the instinct to mirror your API one-to-one. If you have 40 endpoints, you probably want 10 tools shaped around tasks rather than endpoints.
Design around discovery, not enumeration
When a domain is genuinely large, don’t expose everything. Use discover, inspect, act:
- one tool that lists what’s available, with filters
- one that describes a specific thing in detail
- one that acts on it
We did exactly this for widgets: 62 per-widget tools became 5. Nothing was lost, because every widget is still reachable, and per-request cost dropped by roughly ten times. The model asks what exists instead of being handed a catalogue every time.
Return useful errors
When a call fails, the model reads the error and tries again. "Error: invalid input" gives it nothing. "Unknown widget type 'pricing'. Use list-widgets to see available types." gets the next call right.
Errors are prompts. Write them that way.
Make dangerous things hard
If a tool deletes, overwrites, or affects everything, it should be:
- off by default, opted into deliberately
- confirm-gated, requiring an explicit
confirm: truein the call - permission-checked against a real authorisation system, not one you invented
The rule that keeps you honest: a server should never let the model do more than the authenticating user could do by hand.
Practical decisions
Transport. Start with stdio, it’s simpler, runs locally, and touches no network. Move to HTTP when the thing you’re wrapping is remote, and use OAuth when you do.
State. Prefer stateless tools. Each call should be complete on its own. Hidden state between calls produces bugs that are miserable to reproduce, because the model doesn’t know the state exists.
Schemas. Be specific. status: z.enum(['draft','publish']) prevents an entire category of wrong call that status: z.string() invites.
Naming. Verb-noun, consistently: list-posts, get-post, update-post. Consistency helps the model generalise from one tool to the next.
Testing
Two things worth automating early:
Test the tool list. Assert the exact set of tools your server registers. Silent registration failures are surprisingly common, and a missing tool doesn’t error, it just never gets called. We shipped an integration once where a required field was omitted and the framework silently dropped the ability. No error, no tool, and it was only caught by an assertion on the live registry.
Test permission delegation. If a tool checks authorisation, prove it refuses when it should. This is the test you’ll be glad of.
Where to go next
- The official specification — protocol, SDKs, and the Inspector
- The reference servers —
Everythingexercises every protocol feature - The MCP servers worth knowing — how mature servers handle these decisions
- Tools overview — one server’s approach to organising 200+ tools
