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 McpServer with 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 vitest suite that drives the server in-process over an in-memory transport.
  • Registration with Claude Code.

Prerequisites

  • Node.js 18+ and npm (brew install node on macOS). Verify node --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 (tsc emits 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/sdk is the official SDK: McpServer, the transports, and a matching Client. zod describes tool input schemas; the SDK accepts zod 3.25+ or 4.x, so zod 4 is fine.
  • "type": "module" makes Node treat .js files as ESM, which the SDK requires. bin exposes the built entry as a command, handy if you later publish it (Step 6 adds the shebang that makes that work).
  • vitest is 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 .js extension (for example import { add } from "./tools.js") even though the source file is tools.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; vitest runs 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. inputSchema is a raw shape — an object of zod validators, not a wrapped z.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 a type and payload. type: "text" is the common case.
  • registerResource(name, uri, metadata, handler) exposes read-only data at a URI. The handler returns contents, each tagged with the uri it came from.
  • createServer returns 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 node makes the compiled dist/index.js runnable directly, which the bin entry needs. tsc preserves the shebang in its output.
  • StdioServerTransport reads JSON-RPC from stdin and writes it to stdout. Because the protocol owns stdout, all logging must go to stderr (console.error). A stray console.log corrupts the stream and the client disconnects — the single most common way a working server breaks once launched over stdio.
  • server.connect(transport) starts serving. The catch logs 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

  • StdioClientTransport spawns the exact command a real client would (node dist/index.js) and manages the subprocess. client.connect runs the initialize handshake.
  • The output confirms the whole path: tools are advertised, add returns 5, greet with shout: true shouts, 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.ts exercises the pure functions with no MCP involved — the payoff for keeping logic out of the server module.
  • server.test.ts uses InMemoryTransport.createLinkedPair() to wire a Client directly 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), the shout default, 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 make prints the help screen listing every target.
  • register-code depends on build, so the dist/ 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' or ERR_MODULE_NOT_FOUND. Under NodeNext, relative imports need the .js extension: 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" in package.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 get shows Failed to connect. Run node dist/index.js directly — it should start and wait. If it errors, you likely forgot npm run build, so dist/ is missing or stale.
  • A zod type error on inputSchema. Pass a raw shape ({ a: z.number() }), not a wrapped z.object({ ... }).

Recap

  • The official SDK builds an MCP server from an McpServer plus registerTool / registerResource, with zod raw shapes for input schemas.
  • StdioServerTransport serves it as a subprocess; stdout is the protocol, so logs go to stderr.
  • InMemoryTransport links 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; the bin entry 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.