Engineering · AI
Building Your First MCP Server
Mihajlo Petrović5 min read
MCP lets you write one integration that any AI client can use. A minimal working server in TypeScript, plus the design lessons that only show up after you have built one badly.
Giving an AI agent access to your own systems used to mean writing a bespoke integration for every assistant you wanted to support. MCP — the Model Context Protocol — is the open standard that replaced that: you write one server, and any MCP-aware client can use it.
I've built a few now, for work and for side projects. Here's what the thing actually is, a minimal working server, and the design lessons that only show up after you've built one badly.
What an MCP Server Actually Is
Strip away the branding and it's a small program that speaks JSON-RPC over stdio (a local subprocess) or HTTP (a remote service), and exposes three kinds of thing:
- Tools — actions the model can invoke.
search_orders,run_query,create_ticket. This is 90% of what you'll write. - Resources — data the client can read and put into context. A config file, a schema, a document.
- Prompts — reusable prompt templates the user can trigger deliberately.
The client (your IDE agent, a desktop app, your own application) discovers what's available, decides when to call it, and feeds the result back to the model. Your server never talks to the model directly — it just answers calls. That separation is the whole point: your server has no idea which model is on the other end.
A Minimal Server
The TypeScript SDK is @modelcontextprotocol/sdk. A server that exposes one tool is about 30 lines:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({ name: "deploy-status", version: "1.0.0" });
server.tool(
"get_deploy_status",
"Return the status of the most recent deploy for an environment.",
{ environment: z.enum(["staging", "production"]) },
async ({ environment }) => {
const status = await fetchDeployStatus(environment);
return {
content: [
{
type: "text",
text: `${environment}: ${status.state} (${status.sha}, ${status.finishedAt})`,
},
],
};
}
);
await server.connect(new StdioServerTransport());
That's a complete server. Point a client at it (node dist/server.js as the command) and the agent can now answer "is production green?" by actually checking, instead of guessing.
The SDK's surface moves faster than blog posts do — check the current README before you copy the exact call signature. The shape above, though, has been stable: declare a name, a description, a schema, and an async handler.
Debugging tip: on a stdio server,
console.loggoes into the protocol stream and corrupts it. Log tostderr(console.error) or a file. This wastes an hour of everyone's life exactly once.
The Design Lessons
The description is the API
The model chooses tools by reading their names and descriptions. That text is not documentation for humans, it's the interface — and a vague description produces a tool that's either never called or called at the wrong time.
Bad: "Query the database."
Good: "Run a read-only SQL SELECT against the analytics replica. Use for questions about order volume, revenue, and customer counts. Not for writes or for production data."
Say what it's for and what it isn't for. Most tool-selection mistakes are description mistakes.
Few good tools beat many granular ones
Every tool definition sits in context on every request. Twenty granular tools (get_user_by_id, get_user_by_email, get_user_by_phone) cost more and choose worse than three well-designed ones with parameters.
Design them at the level of tasks a user would name, not at the level of your internal API surface.
Return prose, not raw JSON
A 4,000-token JSON payload is a terrible tool result. The model has to parse it and most of it is irrelevant. Return the summary the model actually needs, in text, with an option to drill in.
Think about it as: what would a helpful colleague say if you asked this question over Slack? They wouldn't paste the API response.
Read-only by default
My rule, and I'd argue it should be everyone's: read operations need no ceremony; write operations need an explicit, narrowly-scoped tool and a confirmation path. Do not build execute_sql with write access and hope the model is careful. Build create_ticket with three fields.
And never give a server production write credentials because it was convenient for testing.
Errors are information
Return errors as content the model can read and act on — "No deploy found for 'prod'. Valid environments are 'staging' and 'production'." — not as a thrown exception that surfaces as a generic failure. A good error message lets the agent correct itself on the next turn instead of giving up or hallucinating.
When to Build One (and When Not To)
Build a server when the agent needs live, private, or system-specific data: your ticket tracker, your database schema, your deploy state, your internal docs. That's information that cannot be in the model's weights and would be tedious to paste.
Don't build one when a script would do. If the workflow is "run this command and read the output", the agent can already run commands — an MCP server adds a process, a protocol, and a maintenance burden for nothing.
And check whether one already exists before you write it. There are solid community and vendor servers for the common systems; your time is better spent on the server that only your company could write.
The Payoff
The first time an agent answers a question about your production system by querying it rather than by pattern-matching on your codebase, the difference is obvious. The gap between "plausible answer" and "correct answer" is exactly the gap between an agent guessing and an agent with tools.
That's the actual point of MCP. Not the protocol — the fact that you only have to write the integration once, and that every agent workflow built on top of it gets sharper as a result (more on that stack in the agents-in-practice post).
- #mcp
- #ai agents
- #typescript
- #tooling
- #node
Written by
Mihajlo Petrović
Software engineer in Belgrade. Builds his own products and the AI automations that keep them running.