How to build a production MCP server in TypeScript
MCP lets an AI agent call your tools and read your data. Here's how to build a server that does it properly — typed, authed, and deployable.
The Model Context Protocol (MCP) is the emerging standard for connecting AI agents to the outside world. An MCP server exposes tools (functions the agent can call), resources (data it can read), and prompts — and any MCP-aware client, from Claude to Cursor to your own agent, can use them. If you've ever wanted your agent to query your database, hit an internal API, or read your files, an MCP server is how you do it without hard-coding anything into the model.
Pick your transport (you probably want both)
MCP servers speak over a transport. There are two that matter, and a real product supports both:
- stdio — the server runs as a local process the client launches. This is how desktop clients connect. Simple, no networking, no auth needed because it's local.
- HTTP + SSE — the server runs as a hosted endpoint clients connect to over the network. This is what you deploy. It needs auth, rate limiting, and CORS thought through.
Build your tool logic once, then expose it over whichever transport the runtime selects. Don't fork your codebase per transport — that's where most hand-rolled servers go wrong.
Define a tool the right way
A tool is a name, a schema for its input, and a handler. Validate the input with a schema library (zod is the norm) so a malformed call from the model fails cleanly instead of throwing deep in your code. Return structured content, and return errors as data the agent can read — not as unhandled exceptions.
server.tool(
"get_order",
{ orderId: z.string() }, // validated input
async ({ orderId }) => {
const order = await db.orders.find(orderId);
if (!order) return { isError: true, content: [{ type: "text", text: "No such order" }] };
return { content: [{ type: "text", text: JSON.stringify(order) }] };
}
);Auth and safety are not optional on a remote server
The moment your server is reachable over HTTP, it needs a door. At minimum:
- Require an API key or bearer token on every request to the HTTP transport.
- Rate-limit per client so one caller can't hammer your backend.
- Cap input sizes — models can send surprisingly large payloads.
- Never log secrets, and never expose a service-role key or admin credential through a tool. Scope each tool to exactly what it needs.
Deploy it
The HTTP transport deploys like any Node endpoint — Vercel, Fly, or a container. Add a health check, load config from environment variables, and keep it stateless so it scales. It should run comfortably on a free tier until real usage arrives. Then wire the client: give Claude Desktop, Claude Code, or Cursor the server URL and key, and your tools show up in the agent.
The mistakes that bite people
- Unvalidated tool inputs — a bad call crashes the server instead of returning an error.
- One giant tool that does everything — agents work better with small, single-purpose tools.
- No auth on the remote transport — you just exposed your backend to the internet.
- Leaking secrets into tool responses or logs.