Build Your First MCP Server in TypeScript Step by Step
You can build a working MCP server in TypeScript in under fifty lines using the official @modelcontextprotocol/sdk package. This tutorial creates a small server with one tool that converts text to a URL-friendly slug, runs it over stdio and shows how to check it works.
Set up the project
You need a recent version of Node.js. Create a folder and install the SDK, Zod for input schemas, and TypeScript tooling.
mkdir slug-server && cd slug-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node
In package.json, set "type": "module" so that ES module imports work, and add a build script that runs tsc. Then create a tsconfig.json.
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "build",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}
Write the server
Create src/index.ts. The file does four things: creates an McpServer, registers a tool, creates a stdio transport, and connects the two.
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: "slug-server",
version: "1.0.0",
});
server.registerTool(
"slugify",
{
title: "Slugify text",
description:
"Convert a title or phrase into a lowercase, hyphen-separated URL slug.",
inputSchema: {
text: z.string().min(1).describe("The text to convert"),
},
},
async ({ text }) => {
const slug = text
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return { content: [{ type: "text", text: slug }] };
}
);
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("slug-server running on stdio");
}
main().catch((err) => {
console.error("Fatal error:", err);
process.exit(1);
});
What each part does
McpServeris the high-level class. It handles the initialize handshake and thetools/listandtools/callrequests for you.registerTooltakes a name, a config object with a description and aninputSchemawritten as a Zod shape, and an async handler. The SDK converts the Zod shape into JSON Schema and validates incoming arguments before your handler runs.- The handler returns a
contentarray. Here it holds one text item. StdioServerTransportreads JSON-RPC messages from standard input and writes replies to standard output.
Notice the log line uses console.error. With the stdio transport, standard output carries protocol messages. Printing anything else there corrupts the stream. A dedicated post explains this in detail.
Build and run it
npx tsc
node build/index.js
The process prints its log line to stderr and then waits silently. That is correct: it is waiting for a client to send messages on standard input. Press Ctrl+C to stop it.
Check it with the Inspector
The MCP Inspector is an official developer tool that acts as a client. Run it against your built server:
npx @modelcontextprotocol/inspector node build/index.js
It opens a web page. Connect, open the Tools tab, choose slugify, enter "Hello, World! 2024" and run it. You should see hello-world-2024 in the result. If that works, your server is speaking the protocol correctly.
Add a second tool and a resource
A server rarely stays at one tool. Register more by calling registerTool again before connecting. Each tool needs its own unique name. You can also expose data the user can attach as context. This resource returns the list of supported slug rules as plain text.
server.registerResource(
"slug-rules",
"docs://slug-rules",
{ description: "How slugs are built", mimeType: "text/plain" },
async (uri) => ({
contents: [
{
uri: uri.href,
text: "Lowercase, trim, replace non-alphanumerics with hyphens.",
},
],
})
);
Register it before the server.connect call. After rebuilding, the Inspector shows the resource in its Resources tab, and reading it returns the text above. The SDK adds the resources capability to the handshake automatically once you register one.
Make the dev loop faster
Running tsc by hand after every edit gets tedious. Add a "watch": "tsc --watch" script in one terminal and restart the Inspector or client when the build finishes. Tools such as tsx can run TypeScript directly, for example npx tsx src/index.ts, which skips the build step while you develop. For anything you hand to other people, ship the compiled output so they do not need extra tooling.
Also keep the handler small. Put the real logic, here the slug conversion, in a plain function that you can test without MCP at all. The handler then only adapts inputs and outputs. That structure pays off when you add unit tests later in this course.
Common mistakes
- Forgetting
"type": "module", which causes an error about using import outside a module. - Leaving out the
.jsextension in SDK import paths. The SDK paths end in.jseven in TypeScript. - Using
console.logfor debugging, which breaks stdio communication. - Returning a plain string instead of the
contentarray shape.
Next steps
Once the Inspector shows the tool working, connect the server to a real client such as Claude Desktop or Claude Code. Those posts come shortly in this course. You can also add a second tool, a resource or a prompt using the same pattern.
Comments
No comments yet. Have a question or a tip? Be the first to share it.