← All posts

How to build an MCP server

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.

Anatomy of one tool update-post name · the identifier description what it does when to use · what it won't do input schema title, status, content — typed handler( ) runs the action The model reads this to choose it schema + handler are what runs
The description is a UX surface aimed at a machine — the model picks the tool from that one line, long before any code runs.
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.

62 per-widget tools 5 tools ~10× less per-request cost list what exists describe one in detail act on it
The model asks what exists instead of being handed a catalogue on every request.

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: true in the call
  • permission-checked against a real authorisation system, not one you invented
Three gates before a destructive call off by default opt in on purpose confirm: true explicit in the call permission check real auth system ! the action runs e.g. delete-all
A server should never let the model do more than the authenticating user could do by hand.

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

← All postsSubscribe via RSS