An LLM on its own is a very good text predictor. What makes it useful is the tools you hand it: the ability to read a file, query a database, hit an API, book a meeting. For a while, everyone wired those tools up by hand, per app, in whatever shape their framework happened to like. The Model Context Protocol (MCP) exists to stop that from being bespoke work every single time.
MCP is a small, transport-agnostic protocol for exposing capabilities to an LLM client. Write one server, and any MCP-capable client — Claude Desktop, an agent runtime, an IDE — can discover and call it. This piece is about what it actually takes to build one and run it in production, not just to make it say hello in a demo.
Why a server, and not just a tool
The temptation is to skip the ceremony and register a couple of functions directly inside your agent. That works right up until you have a second agent, or a teammate who wants the same capability, or a client that is not the one you built against. A server gives you a stable boundary: the capability lives in one place, speaks a documented protocol, and is versioned and testable on its own. You are building an integration once instead of re-gluing it into every consumer.
Anatomy of a server
An MCP server exposes three kinds of things. Tools are actions the model can invoke — each has a name, a description, an input schema, and a handler. Resources are readable content the model can pull into context, addressed by URI. Prompts are reusable templates the client can surface to the user. Most servers start with tools and add the rest as needed.
Transport is the other axis. A server can speak over stdio — the client launches it as a subprocess and talks over standard streams, which is how desktop clients usually run local servers — or over HTTP with server-sent events for remote, multi-client deployments. The tool definitions are identical either way; only the wiring at the edges changes.
A minimal server
Here is the shape of a server that registers a single tool. The important part is not the exact method names but the contract: a tool has a name, a schema for its inputs, and a handler that returns content.
server.ts — a single-tool MCP server
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: "weather", version: "1.0.0" });
server.tool(
"get_forecast",
"Get the forecast for a city",
{ city: z.string(), days: z.number().int().min(1).max(7).default(3) },
async ({ city, days }) => {
const data = await fetchForecast(city, days);
return { content: [{ type: "text", text: JSON.stringify(data) }] };
},
);
const transport = new StdioServerTransport();
await server.connect(transport);
That is a real, working server. The model sees get_forecast, its description, and a typed schema; the client validates arguments against that schema before your handler ever runs.
Hardening for production
The gap between that snippet and something you would leave running is entirely in the boring parts. A production server needs to validate inputs beyond the schema (a valid string can still be a malicious URL), retry outbound calls with exponential backoff so a flaky upstream does not surface as a hard failure, and log in a structured way so you can actually trace what the model asked for and what came back. Long-running tools should emit progress notifications instead of going silent, and anything that costs money or hits a rate-limited API needs its own throttle — the client will not do that for you.
Give every tool call a request id and log the tool name, arguments, duration, and outcome as JSON. When an agent does something surprising in production, that trail is the difference between a five-minute fix and an afternoon of guessing.
Treat the server like any other service that takes untrusted input, because that is exactly what it is — the "user" is a language model that can be steered by whatever it just read.
Publishing and wiring up
Once it behaves, package it and publish to npm so consumers can run it with npx, then point a client at it. For a desktop client that means a small block of config naming the command and any environment it needs.
claude_desktop_config.json
{
"mcpServers": {
"weather": {
"command": "npx",
"args": ["-y", "@your-scope/weather-mcp"],
"env": { "WEATHER_API_KEY": "..." }
}
}
}
Restart the client and the tools show up. The whole point of the protocol is that this last step is the same regardless of what your server does inside — you shipped a capability, not another one-off integration.
Start with one tool, get the logging and retries right, and grow from there. The servers that hold up in production are not the ones with the most tools; they are the ones that fail predictably and tell you why.
