Building an MCP Server: A Working One in an Afternoon
A practical walkthrough of writing your first MCP server: choosing the tools, wiring the transport, handling errors, and testing it with a real agent.
Writing an MCP server is much less work than the surrounding discussion suggests. The SDKs handle the protocol, so what you actually write is a handful of functions plus their descriptions. The afternoon goes on deciding which functions, not on plumbing.
This walks through the sequence that produces a server an agent uses well, in the order that avoids the most rework.
Start by watching the task, not by listing endpoints
The instinct is to enumerate your API and expose it. Resist that for an hour and instead do the task manually while writing down every distinct thing you looked up or changed.
You will typically end up with four to eight operations, and they will not map cleanly onto your endpoints. "Find the deployment that broke" is one operation to a human and three API calls; exposing the three calls forces the agent to rediscover the sequence every time, and it will sometimes get it wrong.
Write the list down as sentences before writing any code. If a sentence needs the word "and", it is probably two tools. If two sentences are always spoken together, they are probably one. MCP server design covers the sizing rules in more depth.
Scaffold the server and register one tool
Pick the SDK for your language — the official ones cover TypeScript and Python among others — and create a server with a name and version. Then register exactly one tool and get it working end to end before adding the second.
A tool registration is three things: a name, a description, and a parameter schema. The schema is ordinary JSON Schema, so use the types properly. An enum with four values is far more reliable than a string described as "one of four values", because the enum is enforced and the description is only advice.
Keep the handler thin. It should validate, call your existing service layer, shape the result and return. Business logic living inside a tool handler is logic you cannot test without the protocol, and it will end up duplicated when you add a second tool that needs it.
Run it over stdio first. The host launches your process, so there is no port to configure and no auth to write. Remember that stdout carries the JSON-RPC messages: every log line must go to stderr or the connection dies with a parse error that names nothing useful.
Write the description as if for a new colleague
This is where the quality of the server is decided, and it is the part most people spend the least time on.
Say what the tool does, what it returns, and — most importantly — when not to use it. A description that ends with "use search_logs instead if you do not already have a deployment id" prevents a whole class of wrong turn, because the model reads that sentence at exactly the moment it is deciding.
Put an example of a valid call in the description when the arguments are non-obvious. One concrete example does more for argument accuracy than three sentences of prose about formats.
Name parameters for what they mean rather than what your database calls them. repository_full_name is guessable; repo_fk is not, and the model will fill it with something plausible and wrong.
Shape the return value deliberately
Whatever you return becomes prompt content and competes with the task for attention. Returning your upstream response verbatim ships pagination envelopes, internal ids, nulls and audit timestamps into the model's working memory.
Cap list results and say what was omitted. A response that reports thirty matches shown of two hundred and eighteen tells the model to narrow its query; a silently truncated list of thirty tells it that it has seen everything, and it will confidently reason from an incomplete picture.
Return identifiers the agent can act on, and prefer readable ones where you have the choice. Include units inside values rather than only in the schema, because summaries quote values and drop schemas.
Do not return an entire file when a range would do. Large tool outputs are the main driver of context exhaustion in long sessions, and the fix belongs in the tool rather than in a compaction step later. Agent memory and context management covers what happens when you get this wrong.
Make errors actionable
An error message is read by something that will immediately try again, so write it as an instruction. Name the offending parameter, state the expected form, show what was received, and say whether retrying is worth it.
Distinguish the three cases explicitly. A malformed argument is fixable now and should say how. A rate limit is fixable later and should say roughly when. A permission failure is not fixable by the agent at all, and saying so plainly stops it burning six turns trying variations.
Return errors as tool results rather than as protocol-level failures where the SDK allows the distinction. The agent can read and act on a tool result; a transport error often just surfaces as a broken connection with no path to recovery. Agent error recovery patterns covers the loop side.
Add idempotency before you add the second write tool
Agents retry, connections drop, and turns get replayed. Any tool that creates or mutates something will eventually be called twice for one intent.
Accept an optional idempotency key on mutating tools and deduplicate on it for a sensible window. It is a small amount of code and it removes a category of duplicate-record incident that is otherwise very hard to reproduce after the fact.
While you are there, decide which tools are irreversible. Those are the ones the host should gate behind a confirmation, and marking them in the server makes that possible rather than leaving it to whoever configures the client. The MCP security model covers where the gates belong.
Test it with an agent, not with a script
Unit tests confirm your handlers work. They tell you nothing about whether a model can operate your server, which is the actual product.
Give a model three or four realistic tasks and read the full traces. Every wrong tool choice, every malformed argument and every unnecessary extra call is a defect in your descriptions or your schemas, not a model failure. Fix the text, rerun, and watch the trace shorten.
Freeze those tasks as a small suite once they pass, because description edits change behaviour and you will want to know when one makes things worse. Agent regression suites covers turning that into a gate. Then, if the server needs to be shared rather than local, move it to HTTP — the transport options differ mainly in auth and scaling, not in the handlers you have already written.
Common questions
Which transport should a first MCP server use?
stdio. The host launches it as a subprocess, so there is no port, no authentication and no deployment. Move to HTTP only when the server must be shared or holds credentials that cannot live on client machines.
How many tools should I start with?
One, working end to end, before adding a second. Then aim for the four to eight operations you identified by doing the task manually, rather than one tool per API endpoint.
How do I know if my server is any good?
Give a model several realistic tasks and read the traces. Wrong tool choices, malformed arguments and redundant calls are defects in your descriptions and schemas, and fixing the text visibly shortens the trace.