Build MCP Server: Step-by-Step Guide with TypeScript SDK v2
Build an MCP server from scratch: design good tools, write a TypeScript server for stdio and Streamable HTTP, test it, connect it to Claude Code, then publish.
To build an MCP server, pick an official SDK, register a few well-described tools with typed inputs, and serve them over stdio (for local clients) or Streamable HTTP (for remote ones). With the TypeScript SDK v2 that is about 40 lines of code. You test the server with the MCP Inspector, add it to a client such as Claude Code or Cursor, and publish it to npm and the MCP Registry so others can install it.
This guide builds one small, real server end to end. Every snippet was run against @modelcontextprotocol/server 2.3.1, the current stable release (October 2026). It implements the 2026-07-28 MCP specification and still answers older clients. If you are new to the concepts, read what an MCP server is first.
What you need to know first
An MCP server exposes three kinds of things to an AI application:
| Primitive | Who decides to use it | Typical example |
|---|---|---|
| Tools | The model | Call an API, query a database, write a file |
| Resources | The application | A file's contents, a schema, a document |
| Prompts | The user | A reusable template the user picks from a menu |
Most servers are mostly tools. The difference between tools and resources is covered in MCP resources vs tools. The same server code can run over two transports:
- stdio: the client starts your server as a child process and talks to it over stdin and stdout. This is the default for local tools.
- Streamable HTTP: your server is a web endpoint (usually
/mcp) that many clients connect to. This is how you ship a remote server. The old HTTP+SSE transport is deprecated, see is MCP SSE deprecated.
Step 1: Design the tools before writing code
The model only sees each tool's name, description and input schema. Clear tools get called correctly. Vague tools get skipped, or called with bad arguments. A few rules that hold in every language:
- Model tasks, not endpoints.
get-packagethat returns the useful fields is better than three thin wrappers over raw API routes. Fewer tools also cost fewer tokens. See MCP tool discovery. - Write the description for the model. Say what the tool does and when to use it: "Use this before recommending or installing a dependency."
- Type and constrain every input. Enums, min/max values and examples in the description stop most bad calls before your handler runs.
- Return errors the model can act on. "Package not found, check the spelling" lets the model retry. A stack trace does not.
- Mark read-only tools. Hints such as
readOnlyHinthelp clients decide when to ask the user first. They are hints, not security. - Keep secrets out of arguments. API keys come from environment variables or OAuth, never from the model.
The example server has one tool, get-package. It looks up an npm package's latest version and weekly downloads, and needs no API key.
Step 2: Set up the project
You need Node.js 20 or newer.
mkdir npm-info && cd npm-info
npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/server zod
npm install -D typescript tsx @types/node
mkdir src
In v2 the SDK is split into packages: @modelcontextprotocol/server for servers, @modelcontextprotocol/client for clients, plus optional adapters such as @modelcontextprotocol/node and @modelcontextprotocol/express. The old single @modelcontextprotocol/sdk package is the v1 line. If you are upgrading, follow the official migration guide.
Add a tsconfig.json. The SDK README notes that TypeScript 6+ needs "types": ["node"] set explicitly:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "build",
"rootDir": "src",
"strict": true,
"types": ["node"],
"skipLibCheck": true
},
"include": ["src"]
}
Step 3: Write the server
Put the server in a factory function that builds a fresh McpServer. Both transports reuse this one factory. Create src/server.ts:
import { McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
export function createServer(): McpServer {
const server = new McpServer({ name: 'npm-info', version: '1.0.0' });
server.registerTool(
'get-package',
{
title: 'Look up an npm package',
description:
'Get the latest version, description and weekly downloads of an npm package. Use this before recommending or installing a dependency.',
inputSchema: z.object({
name: z
.string()
.min(1)
.describe(
'Exact npm package name, e.g. "zod" or "@modelcontextprotocol/server"'
),
}),
annotations: { readOnlyHint: true, openWorldHint: true },
},
async ({ name }) => {
const meta = await fetch(
`https://registry.npmjs.org/${name.replace('/', '%2F')}/latest`
);
if (!meta.ok) {
return {
content: [
{
type: 'text',
text: `Package "${name}" not found on npm (HTTP ${meta.status}). Check the spelling.`,
},
],
isError: true,
};
}
const pkg = (await meta.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
: undefined;
return {
content: [
{
type: 'text',
text: `${name}@${pkg.version}: ${pkg.description ?? 'no description'}. Weekly downloads: ${downloads ?? 'unknown'}.`,
},
],
};
}
);
return server;
}
The Zod schema is the only schema you write. The SDK turns it into the JSON Schema the model sees, checks each call's arguments against it, and infers the handler's argument types from it. If a call fails validation, the model gets back an isError result it can read and fix.
Step 4: Serve it over stdio
Create src/stdio.ts:
#!/usr/bin/env node
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import { createServer } from './server.js';
void serveStdio(createServer);
console.error('npm-info MCP server running on stdio');
On stdio, stdout carries the protocol. A single console.log corrupts the JSON-RPC stream, so log with console.error.
Step 5: Serve it over Streamable HTTP
For a remote endpoint, wrap the same factory with createMcpHandler. It returns a web-standard fetch handler that builds a fresh server instance for every request. On Node, install the adapter (npm install @modelcontextprotocol/node) and create src/http.ts:
import { createServer as createHttpServer } from 'node:http';
import {
localhostHostValidation,
localhostOriginValidation,
toNodeHandler,
} from '@modelcontextprotocol/node';
import { createMcpHandler } from '@modelcontextprotocol/server';
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.error('npm-info MCP server on http://127.0.0.1:3000/mcp')
);
The handler itself checks no Host or Origin header and no token, so the two localhost guards in front of it block DNS-rebinding attacks. Run npx tsx src/http.ts and check it with curl:
curl -s -X POST http://127.0.0.1:3000/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
You get back one SSE message event that contains the get-package definition. The handler is stateless, so it scales behind any load balancer. Hosting it publicly, with OAuth in front, is covered in how to deploy an MCP server.
Step 6: Test it with the MCP Inspector
The MCP Inspector is the official test client. Open the web UI against the stdio entry point:
npx @modelcontextprotocol/inspector npx tsx src/stdio.ts
Click Connect, open Tools, run get-package with zod, then try an invalid name to see the error path. For CI, use the CLI mode:
npx @modelcontextprotocol/inspector --cli npx tsx src/stdio.ts \
--method tools/call --tool-name get-package --tool-arg name=@modelcontextprotocol/server
Step 7: Connect it to Claude Code and Cursor
A local client needs just one launch command. For Claude Code, put everything after --:
claude mcp add npm-info -- npx tsx /absolute/path/to/npm-info/src/stdio.ts
Run /mcp inside a session to confirm it is connected. For Cursor, add it to .cursor/mcp.json:
{
"mcpServers": {
"npm-info": {
"command": "npx",
"args": ["tsx", "/absolute/path/to/npm-info/src/stdio.ts"]
}
}
}
For the HTTP version, point the client at the URL instead, e.g. claude mcp add --transport http npm-info http://127.0.0.1:3000/mcp. If the client doesn't see your tools, run the launch command by hand first. It should print one line to stderr and then wait. A crash, or anything printed to stdout, is the bug.
Step 8: Publish to npm and the MCP Registry
To let anyone run your server with npx, compile it and add a bin entry. In package.json:
{
"name": "@your-scope/npm-info-mcp",
"version": "1.0.0",
"type": "module",
"bin": { "npm-info-mcp": "build/stdio.js" },
"files": ["build"],
"mcpName": "io.github.your-username/npm-info",
"scripts": { "build": "tsc" }
}
Run npm run build and npm publish --access public. Users can then launch it with npx -y @your-scope/npm-info-mcp.
The official MCP Registry stores metadata only, and the mcpName field is how it verifies that you own the npm package. Following the registry quickstart:
brew install mcp-publisher # or download a release binary
mcp-publisher init # generates server.json
mcp-publisher login github # namespace io.github.your-username/*
mcp-publisher publish
The name in server.json must match mcpName, and the description is capped at 100 characters. A remote-only server can skip npm entirely and list a remotes entry of type streamable-http instead.
Step 9: List it on MCP.so
Last, submit your server to MCP.so so people browsing the MCP server directory can find it. Add a clear README with the install command, required environment variables and a tool list. That README is what users read when they check whether a server is trustworthy.
Other languages
The same design rules and transports apply in every official SDK. TypeScript and Python are the most widely used. C#, Go, Rust and Ruby are also Tier 1, and Java, Swift, PHP and Kotlin are available too (SDK list). For Python, see our Python MCP server guide, which uses the official SDK's MCPServer class and uv.
Next steps
- Browse real servers for design ideas in the MCP server directory. The GitHub MCP server and Context7 are good examples of focused, well-described tools.
- Harden the server before launch with our MCP security best practices.
- Ready to share it? Submit it to MCP.so.
Frequently asked questions
How do I build my own MCP server?
- Pick an official SDK such as TypeScript or Python, register a few tools with clear descriptions and typed inputs, and serve them over stdio for local clients or Streamable HTTP for remote ones. Then test the server with the MCP Inspector and add it to a client like Claude Code or Cursor.
What language is best for building an MCP server?
- TypeScript and Python have the most widely used official SDKs and the most examples. C#, Go, Rust and Ruby are also Tier 1 official SDKs, so the best choice is usually the language your existing code and team already use.
Should my MCP server use stdio or Streamable HTTP?
- Use stdio when the server runs on the user's machine and the client launches it as a child process. Use Streamable HTTP when you host one endpoint that many users connect to, and avoid the deprecated HTTP+SSE transport for new servers.
How do I publish an MCP server so others can use it?
- Publish the package to npm or PyPI so users can run it with npx or uvx, then register its metadata in the official MCP Registry with the mcp-publisher CLI. You can also submit it to directories such as MCP.so so people can find it.