Build Your First MCP Server in TypeScript Step by Step

A
Author
30 Oct, 2026 4 min read 0 views

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

  • McpServer is the high-level class. It handles the initialize handshake and the tools/list and tools/call requests for you.
  • registerTool takes a name, a config object with a description and an inputSchema written 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 content array. Here it holds one text item.
  • StdioServerTransport reads 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 .js extension in SDK import paths. The SDK paths end in .js even in TypeScript.
  • Using console.log for debugging, which breaks stdio communication.
  • Returning a plain string instead of the content array 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.

Was this article helpful?

Comments

No comments yet. Have a question or a tip? Be the first to share it.

Leave a comment

Comments are reviewed before they appear.