Build an MCP Server in TypeScript: Step-by-Step Guide
Build a working MCP server with the official TypeScript SDK, test it with MCP Inspector, connect it to Claude, then declare network access and get it scanned.
Part of our guide to MCP Server Security: How to Vet a Server Before You Use It.
To build an MCP server, install the official SDK, create an McpServer, register one or more tools with a name, description, input schema and handler, then serve it over stdio. With the TypeScript SDK that is about 30 lines of code. You can test it with the MCP Inspector and connect it to Claude Desktop, Claude Code, Cursor or VS Code with a few lines of config.
This tutorial builds a small server with two tools: one that works offline and one that calls a public API. Then it shows how to declare that network access in a manifest so users (and scanners) can check your server only talks to the hosts you say it does. It uses the v2 TypeScript SDK (@modelcontextprotocol/server), which implements the current MCP spec, revision 2026-07-28. If MCP is new to you, read what an MCP server is first. If you already have an API, read MCP vs API to decide whether you need a server at all.
What you need
- Node.js (a current LTS release)
- npm
- An MCP client to test with, such as Claude Desktop or Claude Code
Step 1: Set up the project
mkdir word-tools
cd word-tools
npm init -y
npm install @modelcontextprotocol/server zod
npm install -D @types/node typescript
mkdir src
These are the packages the official build-a-server tutorial uses. In v2 the SDK is split into separate server and client packages. The older all-in-one @modelcontextprotocol/sdk package is the v1 line.
Add "type": "module" and a build script to package.json:
{
"type": "module",
"scripts": {
"build": "tsc"
}
}
Create tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"types": ["node"],
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
Step 2: Write the server
Create src/index.ts:
import { McpServer } from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import { z } from "zod";
const NPM_REGISTRY = "https://registry.npmjs.org";
// serveStdio calls this whenever it needs a server instance,
// so it must return a fresh McpServer every time.
function createServer() {
const server = new McpServer({
name: "word-tools",
version: "1.0.0",
});
// Tool 1: pure computation, no network.
server.registerTool(
"count_words",
{
description: "Count the words and characters in a piece of text.",
inputSchema: z.object({
text: z.string().max(100_000).describe("The text to count"),
}),
},
async ({ text }) => {
const words = text.trim() === "" ? 0 : text.trim().split(/\s+/).length;
return {
content: [
{ type: "text", text: `Words: ${words}\nCharacters: ${text.length}` },
],
};
},
);
// Tool 2: calls one public API.
server.registerTool(
"npm_latest_version",
{
description: "Get the latest published version of an npm package.",
inputSchema: z.object({
name: z
.string()
.regex(/^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/)
.describe("npm package name, for example zod or @scope/pkg"),
}),
},
async ({ name }) => {
const res = await fetch(`${NPM_REGISTRY}/${name}/latest`);
if (!res.ok) {
return {
content: [{ type: "text", text: `Package not found: ${name}` }],
isError: true,
};
}
const data = (await res.json()) as { version?: string };
return {
content: [{ type: "text", text: `${name}@${data.version ?? "unknown"}` }],
};
},
);
return server;
}
serveStdio(createServer, {
onerror: (error) => console.error("word-tools error:", error),
});
console.error("word-tools MCP server running on stdio");
A few details matter here:
registerTool(name, config, handler)is the v2 API. The config holds adescriptionand aninputSchema. The handler receives the validated arguments and returnscontent.serveStdionegotiates the protocol version with each client. The official tutorial notes that wiring a server by hand withserver.connect(new StdioServerTransport())still compiles but only serves older, 2025-era clients.- Logs go to stderr. On stdio, stdout carries protocol messages. A single
console.logcorrupts the stream, so useconsole.error. - Validate inputs. The package name regex stops the model from passing arbitrary paths or URLs into
fetch. Tool arguments come from a model that may have read untrusted text, so treat them like user input. - Report failures with
isError: true. That tells the model the call failed so it can recover.
Which SDK and spec revision should I use?
The current MCP revision is 2026-07-28. It removed the initialize handshake and protocol sessions and added a server/discover method that servers must implement (changelog). Not every SDK's stable release speaks it yet. From the official build-a-server tutorial:
| SDK | What the tutorial says about protocol revisions |
|---|---|
TypeScript v2 (@modelcontextprotocol/server) | serveStdio negotiates per client, answering both 2026-07-28 clients and older clients that send initialize |
| Java | Latest stable release uses 2025-11-25; clients that support only 2026-07-28 cannot connect |
| Kotlin | Latest stable release uses 2025-11-25; same limitation as Java |
| Python, C#, Go, Ruby, Rust | Tutorial does not state a revision; check each SDK's release notes |
If you need to reach clients on both revisions today, TypeScript v2 with serveStdio is the documented option.
Designing good tools
The model only sees each tool's name, description and input schema, so those are your interface:
- One job per tool.
npm_latest_versionis easier for a model to pick correctly than a genericnpmtool with a mode argument. - Describe what it does and returns, in one or two plain sentences. Never put instructions to the model in a description; that is the pattern behind MCP tool poisoning.
- Constrain inputs. Use enums, regexes and length limits in the schema, as Step 2 does for package names.
- Return short, useful text. Large dumps cost the user context. Return what the task needs and let the model ask for more.
- Fail clearly. Return
isError: truewith a message the model can act on, such as "Package not found".
Add a resource and a prompt
Tools are one of three server primitives. Resources are read-only data the client can load, addressed by a URI. Prompts are reusable templates the user picks, usually as a slash command. The v2 SDK registers them the same way as tools, as shown in the SDK docs for resources and prompts. Add these inside createServer():
// A static resource: a name, a fixed URI, metadata and a read callback.
server.registerResource(
"usage",
"word-tools://usage",
{
title: "Usage notes",
description: "How the word-tools server counts words",
mimeType: "text/plain",
},
async (uri) => ({
contents: [
{ uri: uri.href, text: "Words are split on whitespace. Characters include spaces." },
],
}),
);
// A prompt with one required argument.
server.registerPrompt(
"summarise-length",
{
title: "Summarise text length",
description: "Ask for a word count and a one-line summary",
argsSchema: z.object({ text: z.string() }),
},
({ text }) => ({
messages: [
{
role: "user",
content: { type: "text", text: `Count the words in this text, then summarise it in one line:\n\n${text}` },
},
],
}),
);
Check the exact field names against the SDK pages linked above if your SDK version differs; the SDK is still on its v2 line and details can move.
Step 3: Build and test with MCP Inspector
npm run build
npx @modelcontextprotocol/inspector --cli node build/index.js --protocol-era modern --method tools/list
You should see count_words and npm_latest_version. The Inspector CLI defaults to the older protocol era, so the official tutorial recommends running the same command again with --protocol-era legacy to check that older clients work too. Run npx @modelcontextprotocol/inspector node build/index.js without --cli to open the visual UI and call tools by hand.
Write tests
Two kinds of test catch most problems.
Unit tests for the logic. Move the word count into its own exported function in src/count.ts and test it with Node's built-in test runner, with no extra dependencies:
// src/count.ts
export function countWords(text: string): number {
return text.trim() === "" ? 0 : text.trim().split(/\s+/).length;
}
// src/count.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { countWords } from "./count.js";
test("counts words", () => {
assert.equal(countWords("hello world"), 2);
assert.equal(countWords(" "), 0);
});
Build, then run node --test build/.
An integration test that spawns the server and calls a tool. The Inspector CLI is built for this: it starts the server, calls a method and exits, so it fits in CI (Inspector docs):
npx @modelcontextprotocol/inspector --cli node build/index.js --protocol-era modern \
--method tools/call --tool-name count_words --tool-arg text="one two three" --format json
Run it in both the modern and legacy protocol eras so you know both kinds of client work.
Shut down cleanly
Clients stop a stdio server by closing its input or sending a signal. If your server holds anything open, such as a database pool or a temp file, release it on SIGINT and SIGTERM (Node.js signal events):
for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.on(signal, () => {
console.error(`word-tools: received ${signal}, shutting down`);
// close database pools, flush logs, delete temp files here
process.exit(0);
});
}
Log to stderr here too, never stdout.
Step 4: Connect it to a client
For Claude Code:
claude mcp add --transport stdio word-tools -- node /absolute/path/to/word-tools/build/index.js
For Claude Desktop, add it to claude_desktop_config.json and restart the app:
{
"mcpServers": {
"word-tools": {
"command": "node",
"args": ["/absolute/path/to/word-tools/build/index.js"]
}
}
}
Our MCP server install guide has the config file paths and formats for Claude Desktop, Claude Code, Cursor and VS Code.
Prefer Python?
The official Python SDK follows the same pattern. Its current quickstart uses the MCPServer class and needs Python 3.10 or higher (official tutorial):
from mcp.server import MCPServer
mcp = MCPServer("word-tools")
@mcp.tool()
def count_words(text: str) -> str:
"""Count the words and characters in a piece of text."""
words = len(text.split())
return f"Words: {words}\nCharacters: {len(text)}"
if __name__ == "__main__":
mcp.run(transport="stdio")
Install it with uv add "mcp[cli]" or pip install "mcp[cli]". The SDK builds the tool schema from type hints and the docstring.
Publish to npm
Publishing to npm lets users run your server with npx instead of cloning the repo. Three package.json fields matter. The first two match the official tutorial:
{
"name": "@your-scope/word-tools",
"version": "1.0.0",
"type": "module",
"bin": { "word-tools": "./build/index.js" },
"files": ["build"],
"scripts": {
"build": "tsc && chmod 755 build/index.js",
"prepublishOnly": "npm run build"
}
}
binmakesnpx @your-scope/word-toolsstart the server. npm requires the file to start with#!/usr/bin/env node, so add that as the first line ofsrc/index.ts(npmbindocs).fileslimits the published package to the build output, so source, tests and local config stay out.prepublishOnlyruns only onnpm publish, so you never publish a stale build (npm scripts docs).
Then run npm publish --access public. If you also want a listing in the official MCP Registry, see our guide to getting your MCP server discovered.
Step 5: Declare your network access
Users who follow an MCP server security checklist will want to know which hosts your server talks to. Say it up front in a manifest. AgenticMart reads an agent.json file in the repo root, where you list the domains your code contacts and the environment variables it needs:
{
"name": "word-tools",
"description": "Count words and look up npm package versions.",
"type": "mcp",
"entrypoint": "node build/index.js",
"network": ["registry.npmjs.org"],
"env": []
}
When the server is scanned, all outbound traffic is recorded and compared with this list. A domain you did not declare, or a hard-coded IP address, gets flagged in the public report. Without a declaration, any network traffic counts as undeclared. So keep the list accurate and short. If your server needs an API key, list the variable name in env and never commit the value.
If you also publish to the official MCP Registry, you will have a server.json too. AgenticMart can read that file, but it does not hold a list of network domains, so agent.json is where you declare egress.
Step 6: Get it scanned and listed
Push the repo to GitHub, then submit it for a free scan. The scan runs on a fresh virtual machine that is destroyed afterwards. It runs Semgrep with custom rules (eval, shell exec, hidden environment variable reads, obfuscation), Gitleaks for secrets and OSV-Scanner for vulnerable dependencies. It then installs and starts your server in a Docker container with all Linux capabilities dropped, running as a non-root user, and lists its tools over stdio with tools/list. It sends an initialize request first, which servers on pre-2026-07-28 revisions expect; it does not yet call server/discover. A server built with serveStdio answers both. The probe runs three times, and three matching clean runs earn the reproducibility badge.
The results become badges and a 0 to 100 score on a public listing page, and once listed the server appears in the MCP server directory. You can read exactly what each check does on the verification page, and how listings work on the developers page. A scan is a malware and risk check, not a safety guarantee, and the report says so.
Before you submit, run through the same checks yourself:
- Keep tool descriptions short and factual. Never put instructions to the model in them. See MCP tool poisoning for why.
- Pin dependencies with a lockfile and commit it.
- Keep secrets out of the repo and out of logs.
- Ask for the minimum: narrow file paths, read-only tokens, few environment variables.
Once your server is listed, our guide on how to get your MCP server discovered covers the MCP Registry, naming and getting your first users.
FAQ
What language should I use to build an MCP server?
Use the language your team already knows. There are official SDKs for TypeScript and Python, and also Java, Kotlin, C#, Go, Ruby and Rust, as listed in the official build-a-server tutorial. TypeScript and Python have the most examples.
What is the difference between @modelcontextprotocol/sdk and @modelcontextprotocol/server?
@modelcontextprotocol/sdk is the v1 TypeScript SDK, which bundles client and server code. The v2 SDK splits them into @modelcontextprotocol/server and @modelcontextprotocol/client and supports the 2026-07-28 spec. New projects should start on v2.
Why does my MCP server break when I add console.log?
On the stdio transport, stdout carries the JSON-RPC protocol messages. Anything else printed there corrupts the stream and the client drops the connection. Log to stderr with console.error, or use a logger that writes to stderr or a file.
Does my MCP server need to implement server/discover?
Under the 2026-07-28 revision, yes: servers MUST implement it (changelog). With the TypeScript v2 SDK, serveStdio handles both server/discover and the older initialize flow for you. Test with the Inspector in both --protocol-era modern and --protocol-era legacy.
Should my server expose tools, resources or prompts?
Start with tools, since they are what the model calls. Add a resource when the client should be able to read data without a tool call, and a prompt when users repeat the same request often.
How do I test an MCP server automatically?
Unit-test the logic behind each tool with your normal test runner, then use the Inspector CLI (--cli) in CI to start the server and call tools/list and tools/call. It exits with the result, so a failing call fails the build.
How do I make my MCP server work remotely?
Serve it over the Streamable HTTP transport instead of stdio, behind HTTPS, with authentication. The spec requires HTTP servers to validate the Origin header and recommends OAuth 2.1 for auth. Our comparison of local and remote MCP servers explains the trade-offs.
Browse agents, MCP servers and skills on AgenticMart, see what each can touch, and explore the newest standards on our agent protocol map. List yours free now, paid selling opens soon.