How to Build an MCP Server in TypeScript: A Practical Guide

MCP, the Model Context Protocol, is an open standard for connecting AI assistants to tools and data. Instead of writing a separate integration for each assistant, you write one MCP server, and any compatible client (Claude, ChatGPT, VS Code, Cursor and others) can use it.
In this guide we'll build a small server for a shop: it lets an assistant look up an order and read the shipping policy. It's deliberately simple, but it uses the same pieces you'd use for a real one.
This guide uses version 2 of the official TypeScript SDK, which split the old @modelcontextprotocol/sdk package into @modelcontextprotocol/server and @modelcontextprotocol/client. If you're following an older tutorial, the imports will look different.
The three things a server can offer
- Tools are functions the model can call, such as "get an order" or "create a booking". Each has a name, a description the model reads to decide when to use it, and an input schema.
- Resources are read-only data the client can load as context, such as a policy document or a config file. The application decides when to read them.
- Prompts are reusable templates a user can pick, such as "summarise this ticket".
Most servers start with one or two tools. We'll add a tool and a resource.
Set up the project
You need Node.js 20 or later.
mkdir orders-mcp && cd orders-mcp
npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/server zod tsx
mkdir srcThe SDK ships ES modules only, which is why type=module matters. tsx runs TypeScript directly, so there's no build step while you develop.
Register a tool
Create src/index.ts. The orders array stands in for your real database or API.
import { McpServer } from "@modelcontextprotocol/server"
import { serveStdio } from "@modelcontextprotocol/server/stdio"
import * as z from "zod/v4"
// Stand-in for your database or order API
const orders = [
{ id: "A1001", customer: "Priya", status: "shipped", total: 2499 },
{ id: "A1002", customer: "Rahul", status: "processing", total: 899 },
]
function createServer(): McpServer {
const server = new McpServer({ name: "orders", version: "1.0.0" })
server.registerTool(
"get-order",
{
description:
"Look up a single order by its ID, such as A1001. Returns the status and total.",
inputSchema: z.object({
orderId: z
.string()
.regex(/^A\d{4}$/)
.describe("Order ID, for example A1001"),
}),
},
async ({ orderId }) => {
const order = orders.find((o) => o.id === orderId)
if (!order) {
return {
content: [{ type: "text", text: `No order found with ID ${orderId}.` }],
isError: true,
}
}
return { content: [{ type: "text", text: JSON.stringify(order) }] }
}
)
return server
}
void serveStdio(createServer)
console.error("orders MCP server running on stdio")A few things are worth noticing:
- The description does real work. The model reads it to decide whether this tool fits the user's request, so say what it does and what it returns.
- The Zod schema is the only schema you write. The SDK turns it into the JSON Schema the model sees, checks every call against it before your handler runs, and types the handler's arguments. A call with
{ "orderId": "hello" }is rejected without touching your code. - Errors are results, not exceptions. Returning
isError: truewith a readable message lets the model tell the user what went wrong, or try again with a different input. - Log to stderr. With the stdio transport, stdout carries the protocol itself, so a stray
console.logbreaks the connection. Useconsole.errorfor anything you want to print.
Add a resource
Resources suit data the assistant should be able to read but not change. Add this inside createServer, before return server:
server.registerResource(
"shipping-policy",
"docs://shipping-policy",
{
title: "Shipping policy",
description: "How and when orders are shipped",
mimeType: "text/plain",
},
async (uri) => ({
contents: [
{
uri: uri.href,
text: "Orders ship within two working days. Tracking links are emailed on dispatch.",
},
],
})
)registerResource takes a name, a fixed URI, some metadata and a function that returns the contents. For data with a pattern, such as one resource per customer, the SDK also has ResourceTemplate, which matches a URI pattern instead of a single URI.
Test it with MCP Inspector
Before connecting the server to an assistant, call it directly with MCP Inspector, a small web app that launches your server and shows every request and response:
npx @modelcontextprotocol/inspector npx tsx src/index.tsClick Connect, open the Tools tab, pick get-order and try A1001, then something invalid. Check the Resources tab too. If it works here, it will work in any client, and if it doesn't, the Inspector shows you exactly what was sent.
Connect it to an assistant
Each client has its own place to register servers, but they all need the same thing: the command that starts it. In Claude Code, for example:
claude mcp add orders -- npx tsx /full/path/to/orders-mcp/src/index.tsThen ask something like "What's the status of order A1002?" and the assistant should call your tool.
Before you use it for real
The example above is local and read-only. A server that touches real business data needs a little more care:
- Start read-only. Add tools that change data (refunds, cancellations) only once the read tools have proven reliable, and require confirmation for them.
- Expose small, specific tools. "Get order by ID" is safer and easier for the model to use correctly than "run any database query".
- Validate everything. The schema checks types, but your handler should still check permissions and business rules.
- Keep secrets in environment variables, never in the code.
- Log each tool call with its inputs and result, so you can see what the assistant did and why.
- Go remote only when you need to. Stdio is ideal when the assistant runs on the same machine. To share one server with many users, serve the same
createServerover HTTP and add authentication; the SDK's HTTP guide covers it.
Where to go next
The official TypeScript SDK documentation covers prompts, progress updates and the HTTP transport in detail. If you're deciding what an agent should be allowed to do in the first place, this look at practical AI agent uses may help.
We build MCP servers that connect assistants to real business systems, with permissions and logging set up from the start. If that's what you need, see our custom solutions work.