·10 min read

How to Build an MCP Server in TypeScript, Step by Step

Learn how to build an MCP server in TypeScript: define tools with Zod schemas, add resources and prompts, test with MCP Inspector, and deploy over Streamable HTTP.

To build an MCP server in TypeScript, you install the official @modelcontextprotocol/sdk package, create an McpServer instance, register tools (and optionally resources and prompts) with Zod input schemas, then connect the server to a transport — stdio for local use, Streamable HTTP for remote. That's the whole architecture. Everything else is deciding what your tools should do and writing descriptions good enough that an agent knows when to call them.

This MCP server tutorial walks through all of it end to end. We'll build a small "team notes" server that agents like Claude Code, Cursor, and Windsurf can use to store and search notes, test it with MCP Inspector, convert it to a remote HTTP server, and package it for distribution. If you're new to the protocol itself, read what an MCP server is first — this post assumes you know the basics and want working code.

What You Need to Build an MCP Server

Prerequisites:

  • Node.js 20+ — the SDK targets modern Node, and crypto.randomUUID is global there
  • TypeScript basics — we use strict mode, but nothing exotic
  • An MCP client for testing — Claude Code, Cursor, or just MCP Inspector (no client account needed)

What we're building: a server named team-notes with two tools (add_note and search_notes), one resource template (note://{id}), and one prompt. In-memory storage keeps the example focused; swap in SQLite or an API call where the Map lives and the MCP-facing code doesn't change.

Step 1: Project Setup with the MCP TypeScript SDK

The MCP TypeScript SDK is the official reference implementation, and it does the heavy lifting: JSON-RPC framing, capability negotiation, schema validation, transport handling. You write handlers.

1. Scaffold the project:

mkdir team-notes-mcp && cd team-notes-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node

2. Configure TypeScript. Create tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true
  },
  "include": ["src"]
}

3. Update package.json — the SDK ships ESM, so set the module type and add scripts:

{
  "type": "module",
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js"
  }
}

That's the entire setup. No framework, no config beyond this.

Step 2: Define MCP Tools with Input Schemas

MCP tools are the functions an agent can call. Each one needs a name, a description, and an input schema — and the description matters more than you'd think, because it's the only thing the model reads when deciding whether your tool fits the task.

Create src/index.ts:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

type Note = { id: string; title: string; body: string; tags: string[] };
const notes = new Map<string, Note>();

const server = new McpServer({
  name: "team-notes",
  version: "1.0.0",
});

server.registerTool(
  "add_note",
  {
    title: "Add a note",
    description:
      "Create a new note with a title, markdown body, and optional tags. Returns the new note's id.",
    inputSchema: {
      title: z.string().min(1).describe("Short title for the note"),
      body: z.string().min(1).describe("Markdown body of the note"),
      tags: z
        .array(z.string())
        .optional()
        .describe("Optional lowercase tags for filtering"),
    },
  },
  async ({ title, body, tags }) => {
    const id = crypto.randomUUID();
    notes.set(id, { id, title, body, tags: tags ?? [] });
    return {
      content: [{ type: "text", text: `Created note ${id}` }],
    };
  }
);

server.registerTool(
  "search_notes",
  {
    title: "Search notes",
    description:
      "Search notes by keyword across titles, bodies, and tags. Returns matching notes as JSON.",
    inputSchema: {
      query: z.string().min(1).describe("Keyword to search for"),
    },
  },
  async ({ query }) => {
    const q = query.toLowerCase();
    const hits = [...notes.values()].filter(
      (n) =>
        n.title.toLowerCase().includes(q) ||
        n.body.toLowerCase().includes(q) ||
        n.tags.some((t) => t.includes(q))
    );
    return {
      content: [{ type: "text", text: JSON.stringify(hits, null, 2) }],
    };
  }
);

const transport = new StdioServerTransport();
await server.connect(transport);
console.error("team-notes MCP server running on stdio");

Three things worth calling out:

  1. Zod schemas do double duty. The SDK converts them to JSON Schema for the client's tool listing and validates incoming arguments at runtime. Your handler receives typed, validated input — title is a string, guaranteed.
  2. Log to stderr, never stdout. On the stdio transport, stdout is the protocol channel. A single stray console.log corrupts the JSON-RPC stream and produces baffling client-side parse errors. Use console.error for diagnostics.
  3. Fewer, sharper tools beat many vague ones. Every registered tool costs context-window space in every conversation the client attaches it to. If you're tempted to expose twenty endpoints, read up on what too many MCP tools does to your token budget first.

Step 3: Add Resources and Prompts

Tools are model-controlled: the agent decides when to call them. MCP also supports resources (application-controlled context, addressed by URI) and prompts (user-invoked templates). Most servers only need tools, but both take a few lines when you want them.

A resource template makes each note addressable:

import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";

server.registerResource(
  "note",
  new ResourceTemplate("note://{id}", { list: undefined }),
  {
    title: "Note",
    description: "A single team note, addressable by id",
  },
  async (uri, { id }) => {
    const note = notes.get(String(id));
    if (!note) {
      throw new Error(`No note with id ${id}`);
    }
    return {
      contents: [
        {
          uri: uri.href,
          mimeType: "text/markdown",
          text: [`# ${note.title}`, "", note.body].join("\n"),
        },
      ],
    };
  }
);

A prompt packages a repeatable workflow the user can trigger explicitly:

server.registerPrompt(
  "summarize_notes",
  {
    title: "Summarize notes",
    description: "Summarize all notes matching a keyword",
    argsSchema: { query: z.string() },
  },
  ({ query }) => ({
    messages: [
      {
        role: "user",
        content: {
          type: "text",
          text: `Use the search_notes tool with query "${query}", then summarize the results as a short bullet list.`,
        },
      },
    ],
  })
);

Register everything before calling server.connect() — clients cache the capability list they receive at initialization.

Step 4: Test Your Server with MCP Inspector

Don't wire the server into a real client until it works in isolation. MCP Inspector is the official debugging UI, and it runs your server as a subprocess with one command:

npm run build
npx @modelcontextprotocol/inspector node dist/index.js

Inspector opens in your browser. Click Connect, then walk the tabs: the Tools tab should list add_note and search_notes with their schemas rendered as forms; call add_note, then search_notes, and confirm the round trip. Check Resources and Prompts the same way. If a tool is missing, you registered it after connect(); if the connection dies instantly, something is writing to stdout. Our MCP Inspector debugging guide covers the full failure-mode catalog.

Once Inspector is green, add it to a real client. For Claude Code:

claude mcp add team-notes -- node /absolute/path/to/dist/index.js

Then ask the agent to "add a note about the staging database credentials rotation" and watch it pick the right tool from your descriptions alone.

Step 5: Go Remote with Streamable HTTP

Stdio is perfect for local, single-user servers, but it can't be shared: every user runs their own copy of your code with their own credentials. Streamable HTTP is the modern remote transport — one deployed server, many clients. (SSE, the older remote transport, is deprecated; the transport comparison explains the migration path.)

First, refactor your registrations into a factory — you'll want a fresh server instance per request in stateless mode. Move everything from Step 2–3 into src/server.ts as export function buildServer(): McpServer, minus the transport lines. Then create src/http.ts:

import express from "express";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { buildServer } from "./server.js";

const app = express();
app.use(express.json());

app.post("/mcp", async (req, res) => {
  const server = buildServer();
  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: undefined, // stateless mode
  });
  res.on("close", () => {
    transport.close();
    server.close();
  });
  await server.connect(transport);
  await transport.handleRequest(req, res, req.body);
});

app.listen(3000, () => {
  console.log("MCP server listening on http://localhost:3000/mcp");
});

Stateless mode (sessionIdGenerator: undefined) treats every request independently, which makes the server trivially horizontally scalable and a natural fit for serverless platforms. If you need stateful sessions — server-initiated messages, long-lived subscriptions — generate session IDs and keep a transport map instead; the SDK supports both.

Two production notes before you ship it:

  • Authentication. A remote MCP server is a public API. The MCP spec defines OAuth 2.1 for exactly this; at minimum, put bearer-token checks in front of /mcp before exposing anything real.
  • Note that HTTP mode changed your logging rules back. There's no stdout channel to corrupt anymore — log normally.

Step 6: Distribute Your MCP Server

For a stdio server, distribution means making it runnable with one command. Add a bin entry to package.json:

{
  "name": "team-notes-mcp",
  "bin": { "team-notes-mcp": "dist/index.js" }
}

Add #!/usr/bin/env node as the first line of src/index.ts, build, and publish to npm. Now anyone can wire it into a client with the standard config block:

{
  "mcpServers": {
    "team-notes": {
      "command": "npx",
      "args": ["-y", "team-notes-mcp"]
    }
  }
}

For a remote server, distribution is just a URL plus whatever auth you chose.

One gap remains either way: the config tells a client how to launch your server, but nothing tells the agent how your team actually wants it used — which tools to prefer, what conventions to follow, when not to call it. That's guidance, and guidance travels well as an agent skill. On localskills.sh you can publish a skill — a SKILL.md package, optionally with extra docs and scripts — that encodes those conventions, then install it into Cursor, Claude Code, Windsurf, and other tools in one command; the CLI writes each tool's native format for you. localskills also runs its own MCP server, so agents can search and install skills over MCP with OAuth and scoped access. See the docs for how skills and MCP fit together.

FAQ: How to Build an MCP Server in TypeScript

Do I have to use TypeScript to create an MCP server?

No. Official SDKs exist for Python, Java, Kotlin, C#, and more. TypeScript is a popular choice because the SDK is the reference implementation, Zod gives you schema definition and runtime validation in one step, and npm makes stdio servers trivially distributable via npx.

What's the difference between tools, resources, and prompts?

Tools are model-controlled — the agent decides when to call them and they can have side effects. Resources are application-controlled — read-only context addressed by URI, surfaced by the client. Prompts are user-controlled — reusable templates a person invokes explicitly. If you're unsure, start with tools; they're what most clients support best.

Should I use stdio or Streamable HTTP?

Use stdio when the server runs on the same machine as the client — it's simpler, and secrets stay local. Use Streamable HTTP when multiple people or deployed agents need one shared server. Many projects ship both from the same buildServer() factory with two entry points, which is exactly how we structured Step 5.

Why doesn't the agent call my tool?

Almost always a description problem. The model chooses tools from names, descriptions, and schemas — nothing else. Be specific about what the tool does and returns, describe every parameter, and keep the total tool count small so your server's tools aren't lost in a crowd. Test by asking the agent tasks phrased differently from your description wording.


Building the server is half the job — getting your team's agents to use it well is the other half. Sign up at localskills.sh/signup and publish the conventions around your MCP server as a versioned skill every AI coding tool on your team can share.

npm install -g @localskills/cli
localskills login