webdev.complete
🔌 Model Context Protocol (MCP)
The Modern Frontier
Lesson 114 of 117
35 min

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).

bash
mkdir quote-mcp && cd quote-mcp
npm init -y
npm install @modelcontextprotocol/server zod
npm install -D tsx typescript @types/node
Two SDK generations, two package names
The TypeScript SDK v2 shipped alongside the 2026-07-28 spec and split into separate packages: @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.

server.ts
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

  1. Imports: McpServer is the high-level builder. serveStdio wires it to JSON-RPC over stdin/stdout. Zod supplies the input schema.
  2. The factory function: serveStdio takes 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.
  3. Server instantiation: new McpServer(...) with a name and version. The host displays these to the user.
  4. registerTool: declares the tool. The middle object has metadata (title, description) and an inputSchema. The model uses the description to decide when to call.
  5. Handler: an async function that receives the validated input. It returns an object with a content array. Each item is a typed chunk (text, image, resource). The SDK serializes this back to JSON-RPC.
inputSchema is a schema object now, and not only Zod
In v1, 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.
Descriptions are prompts
The tool description, parameter descriptions, and even your enum values become part of the model's prompt. Write them clearly. "Fetches a quote, may filter by topic" is good. "qfn1 v3" is not.

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.

bash
# 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.ts

The 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:

claude_desktop_config.json
{
  "mcpServers": {
    "quote-of-the-day": {
      "command": "npx",
      "args": ["tsx", "/absolute/path/to/quote-mcp/server.ts"]
    }
  }
}
Use absolute paths
Claude Desktop launches your server from its own working directory. Relative paths will fail mysteriously. Always use absolute paths.

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.

ts
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. Useserver.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.

http.ts
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.

The bare handler does not validate origins
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.
What registerTool actually does for you
Behind that one call sits the JSON-RPC plumbing, schema validation, error mapping, and the per-request capability reporting the stateless protocol needs. Writing an MCP server by hand against the raw spec is a few hundred lines before you get to your first tool.

Quick quiz

Quiz1 / 4

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 typed content array, and inputSchema is a real schema object.
  • serveStdio for local servers, createMcpHandler for remote ones. Both take a factory that returns a server.
  • Connect to Claude Desktop via claude_desktop_config.json with absolute paths.
  • Use the MCP Inspector during development. It is the fastest feedback loop you will have.
Built with Next.js, Tailwind & Sandpack.
Learn. Build. Ship.