Home / Blog / MCP

How to build an MCP server in TypeScript

An MCP server in TypeScript is about 15 lines of real code plus whatever your tool actually does. This guide builds one with the official @modelcontextprotocol/sdk: the setup, a single tool with a zod input schema, the annotations clients read, the stdio transport, and how to test it without wiring up a model first. Every snippet runs as written against the current stable SDK.

When TypeScript is the right call

Build your MCP server in TypeScript when the thing it wraps already lives in the JS or Node world, or when the server runs inside a Node service you already operate. If the tool is a thin layer over an npm client, a Next.js backend, or a Node API you maintain, TypeScript keeps it in one language and one toolchain. That's the deciding factor, not which SDK is "better."

I'll be straight about my own bias: most of the MCP servers I run in production are Python, because their job is HTTP calls and data parsing and Python's libraries make that short work. So if your tool is mostly API-and-data, read the Python version of this guide instead - it's the same shape with a different SDK. But the moment the server needs to call into a Node-only SDK, share types with an existing TypeScript codebase, or deploy alongside a service that's already Node, TypeScript stops being a preference and becomes the obvious choice. One language, one build, one dependency tree.

If you're not yet clear on what an MCP server is at the protocol level, that piece covers the model. Short version for this guide: an MCP server is a small program that advertises a list of tools, and an AI client calls those tools by name with structured arguments. The Model Context Protocol, introduced by Anthropic in November 2024, is the wire format for that exchange. Your job is to define the tools and return results.

Setup: npm, the SDK, and tsconfig

You need two packages: @modelcontextprotocol/sdk and zod. Zod isn't optional here, the SDK uses it to describe tool inputs and generate the JSON Schema clients see. Initialize a project, install both, and tell TypeScript to emit modern ES modules.

Start a fresh project and pull in the dependencies:

npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node

The SDK ships as ES modules, so set "type": "module" in your package.json. Then a minimal tsconfig.json that compiles to Node-compatible ESM:

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

One detail that bites first-timers: because the SDK is ESM, its import paths carry a .js extension even though you're writing .ts files. That's correct, not a typo. The Node16 resolution above is what makes those extensioned imports resolve cleanly.

The minimal server

A working MCP server is one McpServer instance, at least one registered tool, and a transport you connect it to. Here is the whole thing, with a single tool, in one file. Save it as src/server.ts:

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: "hello", version: "1.0.0" });

server.registerTool(
  "greet",
  {
    description: "Return a greeting for the given name.",
    inputSchema: { name: z.string() },
  },
  async ({ name }) => ({
    content: [{ type: "text", text: `Hello, ${name}.` }],
  })
);

const transport = new StdioServerTransport();
await server.connect(transport);

That's a complete server. The McpServer constructor takes a name and version, which the client shows the user. registerTool takes three arguments: the tool name, a config object, and the handler. The handler receives the validated arguments and returns content. Nothing else is required to have a server a model can call.

A tool with a zod input schema

The inputSchema is a plain object whose values are zod types, one per argument. The SDK converts that to JSON Schema for the client and validates incoming calls against it, so by the time your handler runs, the arguments are already typed and checked. You never parse raw input yourself.

Note the shape: it's { name: z.string() }, not z.object({ name: z.string() }). The SDK wants the raw field map, not a wrapped object, and it builds the object schema for you. Here's a slightly more real tool, one that converts a unit and takes two typed arguments:

server.registerTool(
  "convert_temp",
  {
    description: "Convert a temperature between celsius and fahrenheit.",
    inputSchema: {
      value: z.number().describe("The numeric temperature to convert"),
      to: z.enum(["c", "f"]).describe("Target unit: c or f"),
    },
  },
  async ({ value, to }) => {
    const result = to === "f" ? value * 9 / 5 + 32 : (value - 32) * 5 / 9;
    return { content: [{ type: "text", text: `${result.toFixed(1)} °${to}` }] };
  }
);

Two things earn their keep here. The .describe() calls become per-argument descriptions in the schema the model reads, and a model picks arguments far more reliably when each one says what it's for. And the z.enum means an out-of-range unit is rejected by validation before your code runs, so the handler never sees a value it didn't expect. Spend your effort on the schema; it's the contract the model actually reads.

Annotations: title and readOnlyHint

Give every tool an annotations block with a human title and the right behavior hint, usually readOnlyHint for tools that only read. These don't change what the tool does; they tell the client, and the person approving the call, what to expect before it runs. A read-only tool marked as such can be auto-approved more safely than one that might write.

Annotations go in the same config object as the schema. Here's the temperature tool again, now declaring that it's read-only and self-contained:

server.registerTool(
  "convert_temp",
  {
    title: "Convert temperature",
    description: "Convert a temperature between celsius and fahrenheit.",
    inputSchema: {
      value: z.number().describe("The numeric temperature to convert"),
      to: z.enum(["c", "f"]).describe("Target unit: c or f"),
    },
    annotations: {
      title: "Convert temperature",
      readOnlyHint: true,
      openWorldHint: false,
    },
  },
  async ({ value, to }) => {
    const result = to === "f" ? value * 9 / 5 + 32 : (value - 32) * 5 / 9;
    return { content: [{ type: "text", text: `${result.toFixed(1)} °${to}` }] };
  }
);

The hints are advisory, not enforced. readOnlyHint: true does not stop your handler from writing; it's a promise you're making to the client. So if you set it, mean it. For anything that does mutate state, drop readOnlyHint and add destructiveHint: true instead, and let the client require a human approval. openWorldHint: false here says the tool's result depends only on its inputs, not on some external system that might change between calls.

The rule: annotations describe intent, they don't enforce it. If a tool can write or delete, the boundary that stops it has to live in your handler code, not in a hint. The hint just tells the client which tools are safe to run without asking.

Connecting stdio and running it

For a local server the client spawns, use StdioServerTransport. It speaks JSON-RPC over stdin and stdout, so there's no HTTP server, no port, and no network setup. Construct it, await server.connect(transport), and the process now answers MCP requests on its standard streams.

That's the two lines at the bottom of the minimal server above. Compile and run it:

npx tsc
node dist/server.js

The process will sit there waiting for JSON-RPC on stdin, which looks like nothing happening. That's correct: a stdio server isn't meant to be talked to by hand, it's meant to be spawned by a client that drives it over the pipe. When you wire it into a client like Claude Desktop or Claude Code, the config names the command and arguments, the same way you'd point at any local server:

{
  "mcpServers": {
    "hello": {
      "command": "node",
      "args": ["/path/to/dist/server.js"]
    }
  }
}

Testing it with the Inspector

Don't wire the server into a model to find out if it works. Run it under the MCP Inspector, the official browser-based tool that spawns your server, lists its tools, and lets you call each one by hand with real arguments. It's the fastest way to confirm the schema and the handler before a client ever sees them.

You don't install anything, npx runs it on demand. Point it at your built server:

npx @modelcontextprotocol/inspector node dist/server.js

It opens a local UI where your tools appear in a list. Click convert_temp, fill in value and to, and run it, the request, the validated arguments, and the response all show up. This is where you catch the real problems: a tool that registered but doesn't appear, a schema the SDK rejected, an argument name that doesn't match what your handler destructures. All of them surface here in seconds, with no model in the loop and nothing to misread your intent.

Three things that trip people up

  1. The .js import extension on .ts files. Imports like "@modelcontextprotocol/sdk/server/mcp.js" are right even though the source is TypeScript. ESM resolution needs the runtime extension. Drop it and the import fails at runtime, not at compile time, which is a confusing way to lose an hour.
  2. Passing z.object(...) as the inputSchema. The SDK wants the raw field map, { name: z.string() }, and wraps it for you. Hand it a full z.object() and the tool's schema comes out wrong. This is the single most common mistake porting from other examples.
  3. Logging to stdout. Stdout is the protocol channel for a stdio server. A stray console.log writes garbage into the JSON-RPC stream and the client drops the connection. Send logs to console.error (stderr) instead, which the transport leaves alone.

None of these are protocol problems, they're ESM-and-stdio problems, and they're exactly the kind of thing that doesn't show up until you run the server for real. Build it, point the Inspector at it, and you'll see all three immediately if they're there. The protocol is the easy part. Getting the toolchain and the streams right is the work, and it's the same handful of issues every time.

P

Pavle Lazic is the founder of Scalably, where he builds and runs multi-tenant Claude agent platforms in production for real businesses. He writes about the Claude Agent SDK, MCP servers, and what it actually takes to put AI agents to work. See the platform.