Build an MCP Server
TypeScript SDK. Tools, resources, prompts. stdio and HTTP transport.
Time to stop talking about MCP and build one. By the end of this lesson you will have a complete TypeScript MCP server that exposes a single tool, runs over stdio, and is connected to Claude Desktop. Total code: under 50 lines. Total time: 10 minutes. The exact same pattern scales to production servers with dozens of tools.
What we're building
A quote-of-the-day MCP server. It exposes one tool called get_quote that returns an inspirational quote. The model can request a quote at any time, optionally filtered by topic. Boring? Yes. Educational? Also yes. You will see every moving part of an MCP server without drowning in business logic.
Step 1: project setup
Make a folder, init a Node project, and install the official SDK plus Zod (used for input schemas).
mkdir quote-mcp && cd quote-mcp
npm init -y
npm install @modelcontextprotocol/server zod
npm install -D tsx typescript @types/node@modelcontextprotocol/server and @modelcontextprotocol/client. If a tutorial tells you to install @modelcontextprotocol/sdk and import from @modelcontextprotocol/sdk/server/mcp.js, it is written for v1. The v1 line still gets bug and security fixes, so existing servers are not broken, but new ones should start on v2. There is also a @modelcontextprotocol/core package; that one is a shared internal dependency, not something you install yourself.Set "type": "module" in your package.json so we can use ESM imports. Then create server.ts.
Step 2: the full server
Here it is. Read it once top to bottom, then we will walk through what each chunk does.
import { McpServer } from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import * as z from "zod/v4";
const quotes: Record<string, string[]> = {
general: [
"The way to get started is to quit talking and begin doing.",
"Premature optimization is the root of all evil.",
"Make it work, make it right, make it fast.",
],
testing: [
"If it ain't tested, it's broken.",
"Trust, but verify with assertions.",
],
debugging: [
"Everybody knows that debugging is twice as hard as writing a program.",
"When in doubt, console.log it out.",
],
};
serveStdio(() => {
const server = new McpServer({
name: "quote-of-the-day",
version: "1.0.0",
});
server.registerTool(
"get_quote",
{
title: "Get a Quote",
description:
"Returns a quote. Optionally filter by topic: general, testing, debugging.",
inputSchema: z.object({
topic: z
.enum(["general", "testing", "debugging"])
.optional()
.describe("Quote category"),
}),
},
async ({ topic }) => {
const list = quotes[topic ?? "general"];
const quote = list[Math.floor(Math.random() * list.length)];
return {
content: [{ type: "text", text: quote }],
};
}
);
return server;
});Walking through the code
- Imports:
McpServeris the high-level builder.serveStdiowires it to JSON-RPC over stdin/stdout. Zod supplies the input schema. - The factory function:
serveStdiotakes a function that builds and returns a server, rather than a server instance. That indirection is what lets the same code work for HTTP, where the factory runs once per request. - Server instantiation:
new McpServer(...)with a name and version. The host displays these to the user. - registerTool: declares the tool. The middle object has metadata (title, description) and an
inputSchema. The model uses the description to decide when to call. - Handler: an async function that receives the validated input. It returns an object with a
contentarray. Each item is a typed chunk (text,image,resource). The SDK serializes this back to JSON-RPC.
inputSchema took a bare map of field names to Zod validators. In v2 it takes a real schema, so you wrap the fields in z.object(). The v2 SDK validates through Standard Schema, which means Valibot and ArkType work in the same slot if you prefer them to Zod.Step 3: run it
Run it directly with tsx and try the inspector that ships with the SDK. The inspector is a browser GUI that lets you call your tools without wiring up a real host.
# Run the server (it will just sit there waiting on stdin)
npx tsx server.ts
# In a second terminal, launch the inspector pointing at our server
npx @modelcontextprotocol/inspector npx tsx server.tsThe inspector opens at http://localhost:5173. You will see your get_quote tool, can click Call Tool, and watch the round trip. Use this constantly while developing. It is way faster than restarting Claude Desktop for every change.
Step 4: hook it up to Claude Desktop
Claude Desktop reads MCP server configuration from a JSON file. The location depends on your OS:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\\Claude\\claude_desktop_config.json - Linux:
~/.config/Claude/claude_desktop_config.json
Add an entry under mcpServers:
{
"mcpServers": {
"quote-of-the-day": {
"command": "npx",
"args": ["tsx", "/absolute/path/to/quote-mcp/server.ts"]
}
}
}Restart Claude Desktop. Open a chat. Look for the hammer icon (tools) in the message composer. You should see get_quote listed. Ask Claude: "Give me a debugging quote." It will call your tool, pass topic: "debugging", and weave the result into a reply.
Adding a second tool
Adding more is as simple as another registerTool. Tools compose: the model can call one, then another, chaining results.
server.registerTool(
"add_quote",
{
title: "Add a Quote",
description: "Append a new quote to a topic.",
inputSchema: z.object({
topic: z.enum(["general", "testing", "debugging"]),
text: z.string().min(5),
}),
},
async ({ topic, text }) => {
quotes[topic].push(text);
return {
content: [{ type: "text", text: "Added to " + topic + "." }],
};
}
);Going further
- Resources: register URIs your host can read. Use
server.registerResource()with a URI template. - Prompts: register reusable templates with
server.registerPrompt(). These appear as slash commands in many hosts. - Auth: HTTP transports support OAuth flows so users can grant scoped access to their data. Required for any non-trivial remote server. New servers should use Client ID Metadata Documents; Dynamic Client Registration still works but is deprecated.
Going remote: Streamable HTTP
To put this server on the public internet, swap the transport. You keep the same factory and the same registerTool calls; createMcpHandler replaces serveStdio.
import { createMcpHandler, McpServer } from "@modelcontextprotocol/server";
import * as z from "zod/v4";
const handler = createMcpHandler(() => {
const server = new McpServer({ name: "quote-of-the-day", version: "1.0.0" });
// ... the same registerTool calls
return server;
});
// handler.fetch is a standard (Request) => Promise<Response>,
// so this is the whole deployment on Workers, Deno, or Bun.
export default handler;The factory runs once per HTTP request, which is what makes the stateless protocol work: nothing is held between calls, so any instance can serve any request. On Node, wrap it with toNodeHandler(handler) from @modelcontextprotocol/node. There are also createMcpExpressApp, createMcpHonoApp, and createMcpFastifyApp helpers.
createMcpHandler accepts requests with no Host header, no Origin header, and no token. For anything running on localhost that a browser could reach, compose localhostHostValidation() and localhostOriginValidation() in front of it, or use the Express/Hono/Fastify helpers, which turn both on by default. Without that, a malicious web page can reach your local server through DNS rebinding.Quick quiz
What does an MCP server tool handler return?
Recap
- Use
@modelcontextprotocol/server+McpServer+ Zod to build servers fast. - Tools are
name + metadata + inputSchema + handler. The handler returns a typedcontentarray, andinputSchemais a real schema object. serveStdiofor local servers,createMcpHandlerfor remote ones. Both take a factory that returns a server.- Connect to Claude Desktop via
claude_desktop_config.jsonwith absolute paths. - Use the MCP Inspector during development. It is the fastest feedback loop you will have.