The FastMCP tutorials in this series are Python, and FastMCP wraps the Model
Context Protocol so you rarely touch it directly. This article drops down a level
and builds the same kind of server with the official TypeScript SDK
(@modelcontextprotocol/sdk) — the reference implementation Anthropic maintains.
You register tools and resources on an McpServer, connect it to the stdio
transport, and a local client launches it as a subprocess. The stack is Node,
tsc, and vitest.
What you will build
- An
McpServerwith two tools (add,greet) and a resource (info://server). - Pure tool logic kept separate from the MCP wiring, so it unit-tests without a server.
- A stdio entry point, plus a smoke test that launches the built server and speaks MCP to it.
- A
vitestsuite that drives the server in-process over an in-memory transport. - Registration with Claude Code.
Prerequisites
- Node.js 18+ and
npm(brew install nodeon macOS). Verifynode --version. - Familiarity with MCP concepts (tools, resources, transports). No prior SDK experience assumed.
- macOS commands are shown, but the project is cross-platform.
Step 1: Scaffold and add project hygiene
Create the files
mkdir -p mcp-server-typescript-sdk/src
cd mcp-server-typescript-sdk
touch .gitignore
Add the code: .gitignore
# Node
node_modules/
dist/
*.log
# OS / editor noise
.DS_Store
Detailed breakdown
dist/is the compiled output (tscemits JavaScript there); it is rebuilt from source, so it is ignored rather than committed.
Step 2: Dependencies and package setup
The project is ESM (the SDK is ESM-only), so package.json sets
"type": "module".
Create the file
touch package.json
Add the code: package.json
{
"name": "mcp-server-typescript-sdk",
"version": "0.1.0",
"private": true,
"type": "module",
"bin": { "mcp-ts": "dist/index.js" },
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"smoke": "node scripts/smoke.mjs",
"test": "vitest run"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"zod": "^4.4.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0",
"vitest": "^4.0.0"
}
}
Install:
npm install
Detailed breakdown
@modelcontextprotocol/sdkis the official SDK:McpServer, the transports, and a matchingClient.zoddescribes tool input schemas; the SDK accepts zod 3.25+ or 4.x, so zod 4 is fine."type": "module"makes Node treat.jsfiles as ESM, which the SDK requires.binexposes the built entry as a command, handy if you later publish it (Step 6 adds the shebang that makes that work).vitestis the test runner; it executes TypeScript tests directly, no separate build.
Step 3: TypeScript configuration
Create the file
touch tsconfig.json
Add the code: tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
Detailed breakdown
module/moduleResolution: "NodeNext"is the modern ESM setting. It has one consequence that surprises people: relative imports must include the.jsextension (for exampleimport { add } from "./tools.js") even though the source file istools.ts. The extension refers to the compiled output.outDir: "dist"/rootDir: "src"keep sources and build output separate.exclude: ["**/*.test.ts"]keeps tests out of the build;vitestruns them from source.
Step 4: The tool logic
Keep the actual work in plain functions, away from the MCP wiring, so it is trivial to unit-test.
Create the file
touch src/tools.ts
Add the code: src/tools.ts
/**
* The pure logic behind the tools, kept separate from the MCP wiring.
*
* Registering a tool couples a name and a schema to a handler; keeping the
* actual work in plain functions here means it can be unit-tested without a
* server, a transport, or a client.
*/
/** Add two numbers. */
export function add(a: number, b: number): number {
return a + b;
}
/** Build a greeting for a name, optionally shouting it. */
export function greet(name: string, shout = false): string {
const message = `Hello, ${name}!`;
return shout ? message.toUpperCase() : message;
}
Detailed breakdown
- These functions know nothing about MCP. The server (next step) adapts them to the protocol, and the tests (Step 8) call them directly.
Step 5: Construct the server
Build the McpServer and register the tools and resource. This module only
constructs the server; connecting a transport happens elsewhere, so the tests
can reuse it.
Create the file
touch src/server.ts
Add the code: src/server.ts
/**
* An MCP server built on the official TypeScript SDK (@modelcontextprotocol/sdk).
*
* This module only *constructs* the server — it registers tools and a resource
* and returns the McpServer. Connecting it to a transport is done separately
* (see index.ts for stdio), which lets the tests drive the same server over an
* in-memory transport.
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { add, greet } from "./tools.js";
export function createServer(): McpServer {
const server = new McpServer({ name: "mcp-ts", version: "0.1.0" });
server.registerTool(
"add",
{
description: "Add two numbers and return the sum.",
inputSchema: { a: z.number(), b: z.number() },
},
async ({ a, b }) => ({
content: [{ type: "text", text: String(add(a, b)) }],
}),
);
server.registerTool(
"greet",
{
description: "Return a greeting for a name.",
inputSchema: { name: z.string(), shout: z.boolean().default(false) },
},
async ({ name, shout }) => ({
content: [{ type: "text", text: greet(name, shout) }],
}),
);
server.registerResource(
"server-info",
"info://server",
{
title: "Server info",
description: "Static metadata about this server.",
mimeType: "application/json",
},
async (uri) => ({
contents: [
{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify({ name: "mcp-ts", version: "0.1.0" }),
},
],
}),
);
return server;
}
Detailed breakdown
new McpServer({ name, version })creates the server. The name and version are what a client sees during the initialize handshake.registerTool(name, config, handler)is the core call.inputSchemais a raw shape — an object of zod validators, not a wrappedz.object(...). The SDK builds the JSON Schema advertised to clients from it and validates arguments before your handler runs, so{ a, b }arrive already typed as numbers.- The handler returns
{ content: [...] }, where each item has atypeand payload.type: "text"is the common case. registerResource(name, uri, metadata, handler)exposes read-only data at a URI. The handler returnscontents, each tagged with theuriit came from.createServerreturns the server without connecting it, which is what lets Step 8’s tests attach an in-memory transport instead of stdio.
Step 6: The stdio entry point
Connect the server to stdio — the transport a local client (Claude Desktop, Claude Code) launches as a subprocess.
Create the file
touch src/index.ts
Add the code: src/index.ts
#!/usr/bin/env node
/**
* Entry point: connect the server to the stdio transport.
*
* stdio is the transport a local MCP client (Claude Desktop, Claude Code)
* launches as a subprocess. The client speaks JSON-RPC over stdin/stdout, so
* **nothing else may be written to stdout** — logs and diagnostics go to stderr.
*/
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { createServer } from "./server.js";
async function main(): Promise<void> {
const server = createServer();
const transport = new StdioServerTransport();
await server.connect(transport);
// Safe: stderr, not stdout.
console.error("mcp-ts server running on stdio");
}
main().catch((error) => {
console.error("fatal:", error);
process.exit(1);
});
Detailed breakdown
#!/usr/bin/env nodemakes the compileddist/index.jsrunnable directly, which thebinentry needs.tscpreserves the shebang in its output.StdioServerTransportreads JSON-RPC from stdin and writes it to stdout. Because the protocol owns stdout, all logging must go to stderr (console.error). A strayconsole.logcorrupts the stream and the client disconnects — the single most common way a working server breaks once launched over stdio.server.connect(transport)starts serving. Thecatchlogs a fatal error to stderr and exits non-zero so a supervisor notices.
Step 7: Build and smoke-test
Compile, then launch the built server the way a client does and call its tools.
npm run build
Create the file
mkdir -p scripts
touch scripts/smoke.mjs
Add the code: scripts/smoke.mjs
/**
* Smoke-test the built server over stdio, the way a client launches it.
*
* StdioClientTransport spawns `node dist/index.js` and speaks MCP to it, so a
* green run proves the exact command you register with a client works. Build
* first (`npm run build`), then run this.
*/
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
const transport = new StdioClientTransport({
command: "node",
args: ["dist/index.js"],
});
const client = new Client({ name: "smoke", version: "0.1.0" });
await client.connect(transport);
const tools = await client.listTools();
console.log("tools:", tools.tools.map((t) => t.name).sort());
const sum = await client.callTool({ name: "add", arguments: { a: 2, b: 3 } });
console.log("add ->", sum.content[0].text);
const hi = await client.callTool({ name: "greet", arguments: { name: "Ada", shout: true } });
console.log("greet ->", hi.content[0].text);
const info = await client.readResource({ uri: "info://server" });
console.log("resource ->", info.contents[0].text);
await client.close();
Run it:
node scripts/smoke.mjs
tools: [ 'add', 'greet' ]
add -> 5
greet -> HELLO, ADA!
resource -> {"name":"mcp-ts","version":"0.1.0"}
Detailed breakdown
StdioClientTransportspawns the exact command a real client would (node dist/index.js) and manages the subprocess.client.connectruns the initialize handshake.- The output confirms the whole path: tools are advertised,
addreturns5,greetwithshout: trueshouts, and the resource returns its JSON. This is the proof to run before wiring the server into any client.
Step 8: Tests
Unit-test the pure logic, then drive the server in-process with the SDK’s in-memory transport — no subprocess, no stdio.
Create the files
mkdir -p test
touch test/tools.test.ts test/server.test.ts
Add the code: test/tools.test.ts
import { describe, expect, it } from "vitest";
import { add, greet } from "../src/tools.js";
describe("tools", () => {
it("adds two numbers", () => {
expect(add(2, 3)).toBe(5);
expect(add(-1, 1)).toBe(0);
});
it("greets by name", () => {
expect(greet("Ada")).toBe("Hello, Ada!");
});
it("shouts when asked", () => {
expect(greet("Ada", true)).toBe("HELLO, ADA!");
});
});
Add the code: test/server.test.ts
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { beforeEach, expect, it } from "vitest";
import { createServer } from "../src/server.js";
let client: Client;
beforeEach(async () => {
// Link a client and the real server in-process — no subprocess, no stdio.
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await createServer().connect(serverTransport);
client = new Client({ name: "test", version: "0.1.0" });
await client.connect(clientTransport);
});
it("lists the registered tools", async () => {
const { tools } = await client.listTools();
expect(tools.map((t) => t.name).sort()).toEqual(["add", "greet"]);
});
it("calls the add tool", async () => {
const res = await client.callTool({ name: "add", arguments: { a: 40, b: 2 } });
expect(res.content[0].text).toBe("42");
});
it("calls greet with the default shout=false", async () => {
const res = await client.callTool({ name: "greet", arguments: { name: "Ada" } });
expect(res.content[0].text).toBe("Hello, Ada!");
});
it("reads the server-info resource", async () => {
const res = await client.readResource({ uri: "info://server" });
expect(JSON.parse(res.contents[0].text as string)).toEqual({
name: "mcp-ts",
version: "0.1.0",
});
});
Run them:
npm test
Test Files 2 passed (2)
Tests 7 passed (7)
Detailed breakdown
tools.test.tsexercises the pure functions with no MCP involved — the payoff for keeping logic out of the server module.server.test.tsusesInMemoryTransport.createLinkedPair()to wire aClientdirectly to the real server in the same process. It is faster and more deterministic than spawning a subprocess, and it tests the actual registration: the advertised tool list, the tool results (40 + 2 = 42), theshoutdefault, and the resource.
Step 9: The Makefile
Wrap the npm commands so plain make prints help.
Create the file
touch Makefile
Add the code: Makefile
.DEFAULT_GOAL := help
DIR := $(shell pwd)
.PHONY: help install build test smoke start register-code unregister-code clean
help: ## Show this help screen
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
install: ## Install dependencies
npm install
build: ## Compile TypeScript to dist/
npm run build
test: ## Run the vitest suite
npm test
smoke: ## Build, then launch the server over stdio and call its tools
npm run build
node scripts/smoke.mjs
start: ## Run the built server on stdio (Ctrl-C to stop)
node dist/index.js
register-code: build ## Register the built server with Claude Code (local scope)
claude mcp add mcp-ts -- node $(DIR)/dist/index.js
unregister-code: ## Remove the server from Claude Code
-claude mcp remove mcp-ts
clean: ## Remove build output and dependencies
rm -rf dist node_modules
Detailed breakdown
- Plain
makeprints the help screen listing every target. register-codedepends onbuild, so thedist/it points a client at is always current. It uses an absolute path ($(DIR)) because a client may launch the server from any directory.
Step 10: Register with Claude Code
The server is a local stdio program, so register it with the launch command (see
Register a FastMCP Server with Claude Desktop and Claude Code for scopes and the
Claude Desktop config). From the project directory, after npm run build:
claude mcp add mcp-ts -- node "$(pwd)/dist/index.js"
claude mcp get mcp-ts
mcp-ts:
Scope: Local config (private to you in this project)
Status: ✔ Connected
Type: stdio
Command: node
Args: /Users/you/mcp-server-typescript-sdk/dist/index.js
✔ Connected means Claude Code launched the built server and completed the MCP
handshake. Start a session and ask it to use mcp-ts to add 2 and 3.
Troubleshooting
Cannot find module './tools'orERR_MODULE_NOT_FOUND. UnderNodeNext, relative imports need the.jsextension:import { add } from "./tools.js". The extension is the compiled path, even though the source is.ts.require is not defined/exports is not defined. The project must be ESM. Confirm"type": "module"inpackage.json.- The client connects but sees no tools, or disconnects immediately. Something
wrote to stdout. Every log must use
console.error(stderr); stdout is the protocol channel. claude mcp getshowsFailed to connect. Runnode dist/index.jsdirectly — it should start and wait. If it errors, you likely forgotnpm run build, sodist/is missing or stale.- A zod type error on
inputSchema. Pass a raw shape ({ a: z.number() }), not a wrappedz.object({ ... }).
Recap
- The official SDK builds an MCP server from an
McpServerplusregisterTool/registerResource, with zod raw shapes for input schemas. StdioServerTransportserves it as a subprocess; stdout is the protocol, so logs go to stderr.InMemoryTransportlinks a client to the server in-process for fast, deterministic tests, alongside a subprocess smoke test over real stdio.- The built server registers with any MCP client by its launch command
(
node dist/index.js).
Next improvements
- Add the Streamable HTTP transport for remote clients (see Deploy an MCP Server to Cloudflare Workers for the hosted version), keeping stdio for local use.
- Publish to npm so the server installs and runs as
npx mcp-ts; thebinentry and shebang are already in place. - Add prompts with
registerPrompt, exposing reusable prompt templates alongside the tools. - Add OAuth in front of the HTTP transport, the TypeScript counterpart to the Clerk/GitHub auth articles.