Web Dev Drops

How to Build an MCP Server in TypeScript (Step by Step)

avatar
Douglas Matoso
Updated at 10/15/2026
Reading time: 15 min.

AI coding assistants can read your code and run commands, but they don't know about your internal API, your database or the services your team uses. An MCP server is how you give them that access. In this post I will show you how to build an MCP server in TypeScript, step by step, with the official SDK. The short version: install @modelcontextprotocol/server and zod, create an McpServer, register a tool with registerTool(), serve it with serveStdio(), test it in the MCP Inspector and add it to Claude Code, VS Code or Cursor with a single command or JSON entry.

MCP logo and TypeScript logo above the title Build an MCP Server

Everything here was tested with Node.js 24.21, TypeScript 7.0.2 and version 2.3.1 of the official MCP TypeScript SDK, on Windows 11, in October 2026.

What is an MCP server?

MCP (Model Context Protocol) is an open standard for connecting AI applications to external systems. The official docs compare it to a USB-C port: one standard plug, so any AI app can talk to any tool that implements it. Anthropic created it, and in December 2025 donated it to the Agentic AI Foundation, under the Linux Foundation.

There are three roles:

  • Host: the AI application the user talks to (Claude Code, VS Code with Copilot, Cursor, ChatGPT...).
  • Client: the piece inside the host that connects to one MCP server.
  • Server: the program you write. It exposes capabilities the model can use.
Diagram of an AI host connected through an MCP client to an MCP server, which talks to an API, a database and files

An MCP server can expose three kinds of things:

PrimitiveWhat it isWho decides to use itExample
ToolsFunctions the model can callThe modelLook up an npm package
ResourcesRead-only data the app can attach as contextThe applicationThe project's package.json
PromptsReusable message templatesThe user (slash command, menu)"Check my dependencies"

Under the hood, client and server exchange JSON-RPC messages over a transport: stdio when the host starts your server as a local process, or Streamable HTTP when the server runs as a web endpoint.

MCP SDK v1 vs v2: which package to use

Note: Many MCP server tutorials still use the @modelcontextprotocol/sdk package, with server.tool() and imports like @modelcontextprotocol/sdk/server/mcp.js. That's version 1 of the SDK. Version 2 is now the stable release line. It implements the 2026-07-28 MCP specification and splits the SDK into separate packages: @modelcontextprotocol/server and @modelcontextprotocol/client. Version 1 still gets bug and security fixes, but new projects should start on v2, and that's what we'll use here.

How to build an MCP server: the example project

As an example, let's build a small server for web developers called npm-helper, with one of each primitive:

  • a tool, get-package-info, that returns the latest version, description and weekly downloads of an npm package;
  • a resource, project://package.json, with the package.json of the project the server runs in;
  • a prompt, check-dependencies, that asks the model to compare the project's dependencies with the latest versions on npm.

It uses the public npm registry API, so you don't need an API key.

Step 1: Create the project

You need Node.js 20 or later. Create a folder and a package.json:

mkdir npm-helper && cd npm-helper
npm init -y
npm pkg set type=module

npm pkg set type=module makes the project use ES modules, which is what the SDK examples use.

Now install the SDK and Zod, which we'll use to describe the tool inputs:

npm install @modelcontextprotocol/server zod

And the development dependencies: TypeScript, the Node.js types and tsx, which runs TypeScript files directly, without a build step:

npm install -D typescript @types/node tsx

Step 2: Create the server and register a tool

Create a src/server.ts file. It exports a createServer function that builds the server and registers its capabilities:

import { readFile } from 'node:fs/promises'
import { McpServer } from '@modelcontextprotocol/server'
import * as z from 'zod/v4'

export function createServer(): McpServer {
  const server = new McpServer({ name: 'npm-helper', version: '1.0.0' })

  server.registerTool(
    'get-package-info',
    {
      description:
        'Get the latest version, description and weekly downloads of an npm package',
      inputSchema: z.object({
        name: z.string().describe('Package name, e.g. react or @types/node'),
      }),
    },
    async ({ name }) => {
      const res = await fetch(`https://registry.npmjs.org/${name}/latest`)
      if (!res.ok) {
        return {
          content: [{ type: 'text', text: `Package ${name} not found (HTTP ${res.status})` }],
          isError: true,
        }
      }
      const pkg = (await res.json()) as { version: string; description?: string }

      const dl = await fetch(`https://api.npmjs.org/downloads/point/last-week/${name}`)
      const { downloads } = dl.ok ? ((await dl.json()) as { downloads: number }) : { downloads: 0 }

      const text = [
        `${name}@${pkg.version}`,
        pkg.description ?? 'No description',
        `Weekly downloads: ${downloads.toLocaleString('en-US')}`,
      ].join('\n')

      return { content: [{ type: 'text', text }] }
    }
  )

  return server
}

Let's see what's going on here:

  • new McpServer() takes the name and version the server reports to clients.
  • registerTool() takes the tool name, a config and an async handler.
  • The description is what the model reads to decide when to call the tool, so write it for the model: say what the tool does and what it returns.
  • inputSchema is a Zod schema. From it, the SDK generates the JSON Schema the model sees, validates the arguments before your handler runs and infers the handler's types (name is a string here, no extra typing needed). The text in .describe() goes into the JSON Schema as the description of the argument.
  • The handler returns content, a list of blocks. Here it's a single text block.
  • When something goes wrong, return isError: true with a message. The model reads the message and can react to it, for example by trying another name or telling the user what happened.

We'll use readFile in step 5.

Step 3: Run it over stdio

Now create src/index.ts, the entry point that the host will run:

import { serveStdio } from '@modelcontextprotocol/server/stdio'
import { createServer } from './server.js'

void serveStdio(createServer)
console.error('npm-helper MCP server running on stdio')

serveStdio() reads requests from stdin, writes responses to stdout and calls createServer to build the server instance.

Note the ./server.js extension in the import, even though the file is server.ts. That's how TypeScript resolves imports in ES modules.

Run it:

npx tsx src/index.ts

You'll see only this line, and the process keeps waiting:

npm-helper MCP server running on stdio

That's expected: a stdio server waits for a client to send it messages. Stop it with Ctrl + C.

Important! In a stdio server, stdout is the protocol channel. Never use console.log(): any text you print there gets mixed with the JSON-RPC messages and breaks the connection. Log with console.error(), which goes to stderr.

Step 4: Test your MCP server with the MCP Inspector

The MCP Inspector is the official tool to test MCP servers without an AI client. You pass it the command that starts your server:

npx @modelcontextprotocol/inspector npx tsx src/index.ts

It starts your server and opens a web UI at http://127.0.0.1:6274 (the URL in the terminal includes an access token). Click Connect, open the Tools tab, select get-package-info, type a package name like zod and run it.

The Inspector also has a CLI mode, which is handy for quick checks and scripts:

npx @modelcontextprotocol/inspector --cli npx tsx src/index.ts --method tools/call --tool-name get-package-info --tool-arg name=zod

This is what I got:

{
  "content": [
    {
      "type": "text",
      "text": "[email protected]\nTypeScript-first schema declaration and validation library with static type inference\nWeekly downloads: 346,947,379"
    }
  ]
}

With --method tools/list you see the tool as the model sees it, with the JSON Schema generated from Zod:

{
  "tools": [
    {
      "name": "get-package-info",
      "description": "Get the latest version, description and weekly downloads of an npm package",
      "inputSchema": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string",
            "description": "Package name, e.g. react or @types/node"
          }
        },
        "required": ["name"],
        "$schema": "https://json-schema.org/draft/2020-12/schema"
      }
    }
  ]
}

Let's test the validation too. Calling the tool without the name argument, the SDK rejects the call before the handler runs:

Input validation error: Invalid arguments for tool get-package-info: name: Invalid input: expected string, received undefined

Step 5: Add a resource

Tools are for actions. For data the application can read and attach as context, MCP has resources. Add this inside createServer(), before return server:

server.registerResource(
  'package-json',
  'project://package.json',
  {
    title: 'Project package.json',
    description: 'The package.json of the project the server runs in',
    mimeType: 'application/json',
  },
  async (uri) => ({
    contents: [
      { uri: uri.href, mimeType: 'application/json', text: await readFile('package.json', 'utf8') },
    ],
  })
)

registerResource() takes a name, a URI, metadata and a callback that returns the contents. The URI scheme (project://) is up to you.

The path package.json is relative, so it's resolved from the folder where the server process starts. When a host starts the server from your project folder, the resource returns that project's package.json.

Tip: For data with a variable part, like users://{userId}/profile, the SDK has ResourceTemplate. The SDK docs show how to use it.

Step 6: Add a prompt

Prompts are message templates that the user picks, usually as a slash command in the host. Add this one, also before return server:

server.registerPrompt(
  'check-dependencies',
  {
    title: 'Check dependencies',
    description: 'Compare the project dependencies with the latest versions on npm',
  },
  () => ({
    messages: [
      {
        role: 'user',
        content: {
          type: 'text',
          text: 'Read the project://package.json resource. For each dependency, call get-package-info and list the ones that are behind the latest version.',
        },
      },
    ],
  })
)

This prompt ties the other two together: it tells the model to read the resource and then call the tool for each dependency. If your prompt needs arguments, add an argsSchema with a Zod object, just like inputSchema in tools.

Run the Inspector again and check the Resources and Prompts tabs. Or from the CLI:

npx @modelcontextprotocol/inspector --cli npx tsx src/index.ts --method resources/read --uri project://package.json
npx @modelcontextprotocol/inspector --cli npx tsx src/index.ts --method prompts/get --prompt-name check-dependencies

How to add your MCP server to Claude Code, VS Code and Cursor

Every host does the same thing with a local server: it runs a command and talks to the process over stdin and stdout. For us the command is npx tsx src/index.ts. What changes is where you put it.

Claude Code

From the project folder, run:

claude mcp add npm-helper -- npx tsx src/index.ts

Everything after -- is the command that starts the server. Then check the status with claude mcp list:

npm-helper: npx tsx src/index.ts - ✔ Connected

Inside a Claude Code session, the /mcp command shows the server and its tools. By default the server is saved only for you, in this project (local scope). Use --scope project to save it in a .mcp.json file you can commit and share with your team, or --scope user to have it in all your projects (in that case, use the absolute path to the server file). If you're still choosing an AI coding tool, I compared Cursor vs Claude Code and Codex vs Claude Code in other posts.

VS Code

Create a .vscode/mcp.json file in the project:

{
  "servers": {
    "npm-helper": {
      "type": "stdio",
      "command": "npx",
      "args": ["tsx", "src/index.ts"]
    }
  }
}

VS Code asks you to trust the server the first time it starts it. The MCP: List Servers command shows the status, and the tools are available in Copilot Chat in Agent mode.

Cursor

Create a .cursor/mcp.json file in the project (or ~/.cursor/mcp.json to use it in every project):

{
  "mcpServers": {
    "npm-helper": {
      "command": "npx",
      "args": ["tsx", "src/index.ts"]
    }
  }
}

Now try asking the agent something like "what's the latest version of react and how many weekly downloads does it have?". You don't need to mention the tool: the model picks get-package-info from its name, description and input schema. VS Code, for example, asks you to approve the call before it runs.

Build for production

tsx is great while you develop. To ship the server, compile it to JavaScript with tsc. Create a tsconfig.json:

{
  "compilerOptions": {
    "target": "es2022",
    "module": "nodenext",
    "strict": true,
    "outDir": "dist",
    "rootDir": "src",
    "types": ["node"],
    "skipLibCheck": true
  },
  "include": ["src"]
}

Add these scripts to package.json:

"scripts": {
  "dev": "tsx src/index.ts",
  "build": "tsc",
  "start": "node dist/index.js"
}

Run npm run build, and the host command becomes node dist/index.js (with the absolute path, if the host starts it from another folder). If you want to know more about how TypeScript fits in a Node.js project, I wrote about it in Node.js with TypeScript.

Serve your MCP server over HTTP (Streamable HTTP)

stdio is the right choice when the server runs on the same machine as the host. When you want one server that many clients reach over the network (a remote MCP server), use the Streamable HTTP transport.

Diagram comparing a local stdio MCP server started by the host with a remote HTTP MCP server shared by several clients

Since createServer is a separate function, we can reuse it. Install the Node.js adapter:

npm install @modelcontextprotocol/node

And create src/http.ts:

import { createServer as createHttpServer } from 'node:http'
import { createMcpHandler } from '@modelcontextprotocol/server'
import {
  localhostHostValidation,
  localhostOriginValidation,
  toNodeHandler,
} from '@modelcontextprotocol/node'
import { createServer } from './server.js'

const handler = createMcpHandler(createServer)
const nodeHandler = toNodeHandler(handler)
const validateHost = localhostHostValidation()
const validateOrigin = localhostOriginValidation()

createHttpServer((req, res) => {
  if (!validateHost(req, res) || !validateOrigin(req, res)) return
  void nodeHandler(req, res)
}).listen(3000, '127.0.0.1', () => {
  console.log('npm-helper MCP server on http://127.0.0.1:3000/mcp')
})

Here console.log() is fine, because the protocol goes over HTTP, not stdout.

createMcpHandler() calls createServer for each request. Since the 2026-07-28 spec, the protocol is stateless: there's no initialization handshake or session ID anymore, so any instance of your server can answer any request, and it scales horizontally behind a load balancer. The SDK still answers clients that speak the older 2025 versions of the spec, from the same handler.

The two validation functions check the Host and Origin headers and answer 403 to anything that isn't localhost. That protects against DNS rebinding, where a malicious web page tries to reach a server running on your machine.

Run it with npx tsx src/http.ts and test it with the Inspector:

npx @modelcontextprotocol/inspector --cli http://127.0.0.1:3000/mcp --method tools/call --tool-name get-package-info --tool-arg name=react

To add it to Claude Code:

claude mcp add --transport http npm-helper-http http://127.0.0.1:3000/mcp

Important! The handler doesn't check any token. Before you expose an MCP server on the internet, put authentication in front of it. The SDK docs have a guide on authorization with bearer tokens.

Common problems

  • The host doesn't see the server or drops the connection. Run the command by hand (npx tsx src/index.ts). If it crashes, fix the error there. If it prints anything other than the stderr line, look for a console.log().
  • ERR_MODULE_NOT_FOUND: Cannot find package '@modelcontextprotocol/sdk'. You copied v1 code (imports like @modelcontextprotocol/sdk/server/mcp.js) into a v2 project. With v2, import from @modelcontextprotocol/server and @modelcontextprotocol/server/stdio. To migrate existing code, the SDK has a codemod (@modelcontextprotocol/codemod) and an upgrade guide.
  • The model never calls the tool. Improve the description: say what the tool does, when to use it and what it returns. In VS Code, also check that the chat is in Agent mode.

FAQ

What's the difference between MCP and an API?

An API is made for your code: a developer reads the docs and writes the calls. MCP is made for AI applications: the server lists its tools with descriptions and schemas, and the model decides when to call them. The two work together: an MCP server can wrap an existing API, like ours wraps the npm registry.

Do I need Claude to use an MCP server?

No. MCP is an open standard. The same server works in Claude Code, VS Code with Copilot, Cursor, ChatGPT and other hosts that support MCP.

Can I still use @modelcontextprotocol/sdk?

Yes, version 1 still gets bug and security fixes, and old servers keep working. But it implements the older 2025-11-25 spec. For a new MCP server, use the v2 packages.

Should I use stdio or HTTP?

Use stdio for a server that runs on the user's machine, started by the host: it's simpler and needs no port or authentication. Use Streamable HTTP for a remote server shared by many users, and add authentication.

Conclusion

Building an MCP server in TypeScript takes one package, a Zod schema per tool and a few lines to serve it. The parts that need the most care are the tool descriptions, since the model relies on them to decide what to call, and stdout, which belongs to the protocol in a stdio server. Start with stdio and the Inspector, connect the server to your AI tool, and move to HTTP when other people need to use it.

See you next time!

Tags: ai|typescript|nodejs

Comments

Comments disabled