Day 3 of 90 🌱 Spark Phase TypeScript SDK

The TypeScript SDK
Your MCP Toolkit

You know the architecture. You know the wire format. Now it's time to stop reading JSON by hand and use the official SDK to build real MCP servers in TypeScript — with full type-safety, schema validation, and a clean developer experience.

In Days 1–2 you learned the what and how of MCP — the protocol, the primitives, the wire format. Today you learn the toolkit. The @modelcontextprotocol/sdk package abstracts the JSON-RPC plumbing so you can focus on what your server actually does, not how it serializes bytes.
Table of Contents
01 — Why TypeScript

Why TypeScript Is the Natural Fit for MCP

MCP is a protocol spec — it doesn't mandate a language. There are SDKs in Python, Go, Rust, and Kotlin. But the TypeScript SDK is the reference implementation, maintained directly by Anthropic and updated first. It ships with the tightest parity to the spec, the most examples in the wild, and the deepest integration with the Claude Desktop toolchain.

There's also a structural reason TypeScript excels here: MCP messages are JSON objects with typed shapes. TypeScript's type system was designed precisely for this. When your tool's input schema says { "city": "string", "units": "celsius | fahrenheit" }, TypeScript can enforce that contract at compile time, before any request ever arrives.

1stReference SDK
100%Type-safe APIs
ZodSchema validation
~5minTime to first server
🔒
Type Safety
TypeScript catches shape mismatches between your tool input schema and your handler at compile time. No runtime surprises, no manual validation for basic structural correctness.
🔍
IDE Autocomplete
VS Code (and any LSP-enabled editor) gives you full IntelliSense on every SDK method — argument shapes, return types, error variants. Exploration replaces memorization.
First-class Ecosystem
npm has the widest library surface of any MCP-compatible ecosystem. Every API client, data parser, or service wrapper you'd ever want has a TypeScript binding ready to use.
🧰

The Right Tool Analogy

You could drive a screw with a kitchen knife. It will work, sort of, sometimes. The TypeScript SDK is the proper screwdriver — purpose-built. It knows the exact shape of every MCP message, handles the transport layer, manages the handshake, and exposes a clean API surface. Python is the second screwdriver in the box; both fit, but TypeScript is the one Anthropic designed the system with.

📌
Python developers: don't leave

The mcp Python package (via pip install mcp) mirrors almost everything in this day's content. The patterns — McpServer, tool registration, Zod equivalent (Pydantic), stdio transport — translate 1:1. Follow along in TypeScript for the authoritative examples, then switch to Python when building your real server.

02 — Environment Setup

Dev Environment From Scratch

You need four things: Node.js 18+, npm (ships with Node), TypeScript, and the @modelcontextprotocol/sdk package. That's the entire dependency surface for a minimal MCP server. Let's walk through the setup step by step.

01
Verify Node.js 18+
MCP SDK requires Node 18 or higher for native fetch, the AbortController API, and ESM compatibility. Check your version before proceeding.
bash
node --version # must be v18.x or higher npm --version # v9 or higher recommended # If you need to upgrade, use nvm (Node Version Manager): nvm install 20 nvm use 20
02
Initialize Your Project
Create a fresh directory, initialize npm, and install the core dependencies. We use --save-exact to pin the SDK version — MCP spec evolves quickly.
bash
mkdir my-mcp-server && cd my-mcp-server npm init -y # Core MCP SDK npm install --save-exact @modelcontextprotocol/sdk # Zod for schema validation (the SDK re-exports it, but pin your own copy) npm install zod # TypeScript + types npm install --save-dev typescript @types/node tsx
03
Configure TypeScript
Create a tsconfig.json with strict mode and ESM output. The MCP SDK is distributed as ESM — your config must match.
jsontsconfig.json
{ "compilerOptions": { "target": "ES2022", "module": "Node16", // ESM-aware module resolution "moduleResolution": "Node16", "outDir": "./dist", "rootDir": "./src", "strict": true, // catch everything "esModuleInterop": true, "skipLibCheck": true, "resolveJsonModule": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] }
04
Update package.json
Add the "type": "module" field and helpful dev scripts. The tsx tool lets you run TypeScript files directly during development without a build step.
jsonpackage.json (partial)
{ "type": "module", // enable ESM "main": "dist/index.js", "scripts": { "dev": "tsx src/index.ts", // run without build "build": "tsc", "start": "node dist/index.js" } }
05
Create the src/ directory
Your project structure should look like this. All server code lives in src/. We'll create index.ts in Section 6.
treeproject structure
my-mcp-server/ ├── src/ │ └── index.ts # your server entry point ├── package.json ├── tsconfig.json └── node_modules/ └── @modelcontextprotocol/ └── sdk/
💡
Use tsx for development, tsc for production

tsx is a zero-config TypeScript runner — it compiles and runs on the fly, perfect for iteration. For distributing your server or registering it with Claude Desktop, always do a full npm run build first so Claude launches the compiled dist/index.js, not a dev-only tool.

03 — SDK Internals

Inside @modelcontextprotocol/sdk

Before you use a toolkit, understanding its structure builds intuition for where to look when things go wrong. The SDK is published as a single npm package but has a clear internal layout. Here's what's inside node_modules/@modelcontextprotocol/sdk:

@modelcontextprotocol/sdk/
src/ // TypeScript source (shipped in the package)
client/ // Client-side classes (you build servers, not clients)
index.ts // Client class: connects to a server
server/ // ← This is where you live
index.ts // Server base class (low-level)
mcp.ts // McpServer — high-level API you'll actually use
shared/ // Protocol types, schemas, utilities
protocol.ts // Base Protocol class for message routing
schema.ts // Zod schemas for every MCP message type
types.ts // TypeScript interfaces (auto-generated from schema)
transports/ // Transport layer implementations
stdio.ts // StdioServerTransport
sse.ts // SSEServerTransport (for HTTP)
streamable-http.ts // StreamableHTTPServerTransport (newer)

The three layers that matter most to you as a server developer:

🏗️
server/mcp.ts
The McpServer class. This is your primary interaction point. It provides .tool(), .resource(), and .prompt() registration methods with full type inference.
📡
transports/stdio.ts
StdioServerTransport — reads JSON-RPC from stdin, writes to stdout. This is the transport you use for Claude Desktop integration. One line to instantiate.
🔢
shared/types.ts
All the TypeScript types. Tool, Resource, Prompt, CallToolResult, TextContent, ImageContent — import these to type your handlers precisely.
🔬
Two levels of abstraction: Server vs McpServer

The SDK exports both a low-level Server class and a high-level McpServer class. Server gives you raw message routing — you register handlers for individual JSON-RPC methods. McpServer wraps this with ergonomic .tool(), .resource(), and .prompt() methods that handle the tools/list and tools/call routing automatically. Always start with McpServer unless you need protocol-level control.

typescriptImport paths you'll use every day
// The McpServer (high-level — use this) import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; // Stdio transport for local / Claude Desktop use import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; // TypeScript types for your handler signatures import type { CallToolResult, TextContent, ImageContent, EmbeddedResource } from "@modelcontextprotocol/sdk/types.js"; // Zod for schema definitions (companion to input validation) import { z } from "zod";
⚠️
The .js extension in import paths is intentional

When importing from the SDK in an ESM project, you must use the .js extension in the path even though the source file is .ts. This is Node's ESM resolution requirement — TypeScript compiles .ts.js, and the import path must reference the final compiled filename. This trips up nearly every newcomer.

04 — McpServer Class

McpServer Architecture

The McpServer class is the central object in any MCP server you build. Think of it as a registry + router: you register capabilities (tools, resources, prompts) on it at startup, and it handles routing incoming JSON-RPC messages to the right handler, automatically generating tools/list, resources/list, and prompts/list responses from your registrations.

McpServer
from @modelcontextprotocol/sdk/server/mcp.js
new McpServer()
( serverInfo: { name: string; version: string }, options?: ServerOptions )
Creates the server. Name/version go into the initialize response.
.tool()
( name, description, inputSchema, handler )
Register a callable tool. Most common method you'll use.
.resource()
( name, template | uri, metadata, handler )
Register a readable resource (static or template URI).
.prompt()
( name, description, argsSchema, handler )
Register a reusable prompt template.
.connect()
( transport: Transport ) → Promise<void>
Binds the server to a transport and starts the message loop.
.close()
() → Promise<void>
Gracefully shuts down the server, closes the transport.
.server.sendNotification()
( notification: Notification ) → Promise<void>
Send a server-initiated notification (e.g., tools changed).
.server.setRequestHandler()
( schema, handler ) → void
Register a custom handler for any JSON-RPC method (escape hatch to low-level API).

One thing that surprises newcomers: McpServer is not a network server. It knows nothing about ports, HTTP, or sockets. Its only job is protocol-level: parse JSON-RPC messages, dispatch them to handlers, and serialize responses. The transport is a separate object you connect it to. This separation is intentional — it lets you swap stdio for HTTP without changing any of your business logic.

graph LR
  subgraph "Your Code"
    A["McpServer\n(capability registry)"] --> B[".tool() handlers\n.resource() handlers\n.prompt() handlers"]
  end
  subgraph "SDK Infrastructure"
    C["Transport Layer\n(StdioServerTransport\nor SSEServerTransport)"] --> D["JSON-RPC\nMessage Loop"]
  end
  E["Claude Desktop\nor MCP Client"] <-->|"stdin/stdout\nor HTTP+SSE"| C
  D <-->|"parsed messages"| A
  style A fill:#1a0f3a,stroke:#8b5cf6,color:#a78bfa
  style C fill:#0f2a1a,stroke:#10b981,color:#6ee7b7
      
05 — Zod Schemas

The Zod Schema System

Every tool you register needs an input schema — a description of what arguments it accepts. The MCP spec says this schema must be JSON Schema format. The TypeScript SDK uses Zod to define these schemas, then automatically converts them to JSON Schema for the tools/list response.

Why Zod? Because it does double duty: it generates the JSON Schema and validates and parses incoming inputs at runtime. You write the schema once, and you get both the wire-level description and the runtime type guard for free.

📋

Zod is Like a Typed Order Form

Imagine a restaurant order form with checkboxes and dropdowns. It tells customers what they can order (the schema) and prevents them from writing "a pet giraffe" in the entrée field (the validation). Zod is that form for your tool's inputs — it tells the model what parameters are available and validates the actual values before your handler ever runs.

z.string()
z.string().min(1).max(200)
z.string().email()
z.string().url()
String values with optional length and format constraints. Chain validators for precision.
z.number()
z.number().int().positive()
z.number().min(0).max(100)
z.number().multipleOf(0.5)
Numeric values. Distinguish integers from floats, apply range bounds.
z.enum()
z.enum(["celsius", "fahrenheit"])
z.enum(["read", "write", "admin"])
Fixed set of string literals. Great for unit selectors, roles, modes.
z.object()
z.object({
city: z.string(),
units: z.enum([...])
})
Structured objects with typed fields. This is the root schema for most tools.
z.array()
z.array(z.string()).min(1)
z.array(z.number()).max(10)
Lists of typed items. Apply min/max for non-empty or bounded arrays.
.optional() / .describe()
z.string().optional()
z.number().describe(
"Temp in Celsius")
.optional() makes a field nullable. .describe() adds a description to the JSON Schema — the model reads these to understand parameters.
typescriptHow Zod becomes JSON Schema
// You write this Zod schema: const WeatherInput = z.object({ city: z.string().describe("City name to fetch weather for"), units: z.enum(["celsius", "fahrenheit"]).default("celsius").describe("Temperature unit") }); // The SDK converts it to this JSON Schema in tools/list: { "type": "object", "properties": { "city": { "type": "string", "description": "City name to fetch weather for" }, "units": { "type": "string", "enum": ["celsius", "fahrenheit"], "default": "celsius", "description": "Temperature unit" } }, "required": ["city"] // "units" is not required because .default() makes it optional }
TypeScript inference is automatic

When you pass a Zod schema to server.tool(), TypeScript infers the handler's argument type automatically. If your schema is z.object({ city: z.string() }), then inside your handler, args.city is typed as string — no manual type annotation needed. Zod and TypeScript do the work together.

06 — First Server

Building Your First MCP Server

Theory is done. Let's build a complete, working MCP server from scratch. We'll build a Weather Server — it exposes one tool (get_weather), one resource (a static config), and one prompt (a weather report template). This covers all three MCP primitives in a realistic context.

1️⃣
Create and configure McpServer
Instantiate with your server's name and version. These appear in Claude's tools panel.
2️⃣
Register a Tool with Zod schema
Define input shape + handler. The handler returns CallToolResult with typed content.
3️⃣
Register a Resource
Expose readable data at a URI. Can be static or URI template for dynamic reads.
4️⃣
Register a Prompt
A reusable message template the user can invoke directly from the host application.
5️⃣
Connect transport and run
Create the stdio transport, connect the server, and start the async message loop.
typescriptsrc/index.ts — Complete Weather MCP Server
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; // ─── 1. Instantiate the server ───────────────────────────────────────────── const server = new McpServer({ name: "weather-server", version: "1.0.0" }); // ─── 2. Register a Tool ──────────────────────────────────────────────────── server.tool( "get_weather", // tool name (snake_case) "Get current weather for a city", // description (shown to model) { // Zod schema for inputs city: z.string().describe("City name"), units: z.enum(["celsius", "fahrenheit"]) .default("celsius") .describe("Temperature unit") }, async ({ city, units }) => { // handler — args are fully typed! // In a real server, call an actual weather API here. // For this skeleton, we return mock data: const temp = units === "celsius" ? "22°C" : "72°F"; return { content: [{ type: "text" as const, // discriminant for TextContent text: `Weather in ${city}: ${temp}, partly cloudy. Wind: 12 km/h NW.` }] }; } ); // ─── 3. Register a Resource ─────────────────────────────────────────────── server.resource( "weather-config", // resource name "weather://config", // URI (custom scheme is fine) { mimeType: "application/json" }, // metadata async (uri) => ({ contents: [{ uri: uri.toString(), mimeType: "application/json", text: JSON.stringify({ supportedCities: ["London", "Tokyo", "New York"], defaultUnits: "celsius", cacheMinutes: 10 }) }] }) ); // ─── 4. Register a Prompt ───────────────────────────────────────────────── server.prompt( "weather-report", // prompt name "Generate a friendly weather summary", // description { city: z.string().describe("City to report on") }, ({ city }) => ({ messages: [{ role: "user", content: { type: "text" as const, text: `Please use the get_weather tool to check the current weather in ${city} and write a friendly two-sentence summary suitable for a morning briefing.` } }] }) ); // ─── 5. Connect transport and start ─────────────────────────────────────── const transport = new StdioServerTransport(); await server.connect(transport); // Server is now running. Reads from stdin, writes to stdout. // Process stays alive until the client disconnects.
🎯
What happens when you call server.connect()

The server sends nothing — it waits. The client initiates the handshake by sending an initialize request. The transport reads it from stdin, passes it to the server's protocol layer, which responds with initialize result. The client then sends notifications/initialized. After that exchange (≈3 messages, described in Day 2), your tool/resource/prompt handlers become available for calls.

Let's break down the handler return value more precisely. Every tool handler must return a CallToolResult object with a content array. Each item in the array is a content block — the SDK supports three types:

typescriptContent block types in CallToolResult
// TextContent — most common, plain string { type: "text" as const, text: "Hello world" } // ImageContent — base64 encoded image { type: "image" as const, data: "<base64-encoded-bytes>", mimeType: "image/png" } // EmbeddedResource — reference to a resource URI { type: "resource" as const, resource: { uri: "file:///path/to/data.json", mimeType: "application/json", text: "{ ... }" } } // Tool error — set isError: true to signal failure to the model { content: [{ type: "text" as const, text: "City not found" }], isError: true // model sees this as a tool failure, not a protocol error }
07 — Claude Desktop

Connecting to Claude Desktop

Once your server is built, you need to register it with Claude Desktop so the application knows to launch it. Claude Desktop reads a JSON config file at a platform-specific path. You add an entry for each MCP server you want to make available.

⚙️
claude_desktop_config.json
macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
Windows: %APPDATA%\Claude\claude_desktop_config.json
{ "mcpServers": { // Key = name shown in Claude's sidebar "weather-server": { "command": "node", // the runtime to use "args": ["/absolute/path/to/dist/index.js"], // MUST be absolute path "env": { "WEATHER_API_KEY": "your-key-here" // inject secrets via env vars } }, // TypeScript directly (no build step needed during development) "weather-dev": { "command": "npx", "args": ["tsx", "/absolute/path/to/src/index.ts"] } } }

After saving the config, fully quit and relaunch Claude Desktop — the servers are loaded at startup, not dynamically. Look for the hammer icon (🔨) in the Claude chat input area; clicking it shows available tools from all your registered servers.

🚨
3 config mistakes that cause silent failures

1. Relative paths — Claude Desktop resolves paths from its own working directory, not yours. Always use absolute paths.
2. Not rebuilding — editing src/index.ts without running npm run build means Claude is running stale code.
3. Wrong Node path — on some systems "command": "node" resolves to the system Node, not your nvm Node. Use the full path from which node.

Scenario command args Notes
Production (built) node ["/abs/path/dist/index.js"] Fastest startup, recommended
Development (TS direct) npx ["tsx", "/abs/path/src/index.ts"] Slightly slower, no build needed
Global npm package my-mcp-server [] Cleanest for published servers
Python server python3 ["/abs/path/server.py"] Same config format, different runtime
08 — Debugging

Debugging & Testing Your Server

MCP servers run in a subprocess spawned by Claude Desktop, which means you can't just console.log() and see the output in your terminal. You need deliberate debugging strategies. Here are the tools that actually work:

📋
MCP Inspector
The official debugging tool: npx @modelcontextprotocol/inspector. Gives you a browser UI to send JSON-RPC messages, inspect capabilities, and test tools interactively without Claude Desktop.
📁
stderr Logging
Write debug output to stderr, not stdout. Stdout is the JSON-RPC channel — mixing logs into it breaks the protocol. Claude Desktop captures stderr and writes it to log files.
📊
Claude Desktop Logs
Log files live at ~/Library/Logs/Claude/ (macOS) or %APPDATA%\Claude\logs\ (Windows). Each MCP server gets its own log file named after the server key in your config.
🧪
Manual stdin piping
You can test stdio transport by piping raw JSON-RPC messages directly: echo '{"jsonrpc":"2.0","method":"tools/list","id":1}' | node dist/index.js. Inspect the stdout output.
🔍
Type Errors at Build Time
Run npx tsc --noEmit to type-check without generating files. This catches schema mismatches, missing as const on type discriminants, and handler return type errors before runtime.
pino / structured logs
For production servers, use a JSON logger like pino configured to write to a file or stderr. Structured logs are searchable — plain console.error() is fine for early dev.
typescriptCorrect way to log inside an MCP server
// ✅ CORRECT — write debug info to stderr process.stderr.write(`[weather-server] Tool called: get_weather for ${city}\n`); // ✅ Also correct — console.error goes to stderr console.error("[weather-server]", new Date().toISOString(), "Tool called"); // ❌ WRONG — console.log goes to stdout and corrupts the JSON-RPC stream console.log("this will break your server"); // ✅ MCP Inspector usage — run this in a separate terminal // npx @modelcontextprotocol/inspector node dist/index.js // Then open http://localhost:5173 to interact with your server
🔧
MCP Inspector is your best friend

The MCP Inspector tool is the fastest way to test a server before connecting Claude Desktop. It launches your server as a subprocess, handles the handshake, and gives you a form-based UI to call tools and read resources. Think of it as Postman for MCP — an essential part of your development workflow from Day 3 onward.

09 — Knowledge Check

Test Your Understanding

Five questions. Everything from today's content.

Day 3 — TypeScript SDK Quiz

Select one answer per question, then submit to see your score.

Q1 You're writing a tool handler and want to log debug information. Which approach is correct?
A Use console.log() — it's the standard Node.js logging method
B Write to a file — stdout and stderr should both be avoided
C Use console.error() or process.stderr.write() — stdout is reserved for JSON-RPC
D Use process.stdout.write() before the transport connects
C is correct. stdout is the JSON-RPC message channel — any non-JSON data written there corrupts the protocol stream. Always use stderr for logging. console.error() is stderr. console.log() is stdout and will break your server.
Q2 What does the .describe() method on a Zod schema field do?
A Adds a TypeScript comment visible only in your IDE
B Validates that the value matches the description string
C Adds a "description" field to the generated JSON Schema, which the AI model reads to understand the parameter
D Prints documentation to the terminal at server startup
C is correct. Zod's .describe() injects a description field into the JSON Schema output. When the model receives the tools/list response, it reads these descriptions to understand what each parameter means and how to use the tool correctly.
Q3 Why do import paths from the MCP SDK require a .js extension (e.g., "...sdk/server/mcp.js")?
A The SDK is compiled to CommonJS and requires explicit file extensions
B Node.js ESM resolution requires explicit extensions; TypeScript compiles .ts → .js so the import path must reference the final .js file
C It's a quirk of the MCP SDK build system that will be fixed in v2
D The .js extension enables tree-shaking in bundlers
B is correct. This is a Node.js ESM requirement. In CommonJS, Node resolved files without extensions. In ESM (the modern standard), extensions are mandatory. Since TypeScript compiles .ts files to .js, the import must reference the eventual .js output, even while writing TypeScript source.
Q4 What is the difference between McpServer and Server in the SDK?
A McpServer handles HTTP, Server handles stdio
B McpServer is deprecated in favor of Server
C McpServer is the low-level class; Server is the high-level ergonomic wrapper
D McpServer adds ergonomic .tool(), .resource(), .prompt() registration methods on top of the low-level Server base class
D is correct. Server is the base class with raw JSON-RPC method routing. McpServer wraps it with the .tool(), .resource(), and .prompt() methods that automatically handle list/call routing. You should always start with McpServer unless you need protocol-level control.
Q5 You register a tool and its handler throws an unexpected exception. What happens?
A The server process crashes and Claude Desktop restarts it
B The SDK catches the exception and returns a JSON-RPC error response to the client; the server keeps running
C The exception is silently swallowed and the tool returns empty content
D The SDK re-throws the exception and logs it to stdout
B is correct. The SDK wraps handler invocations in try/catch. If your handler throws, the SDK converts the exception into a JSON-RPC error response (code -32603 InternalError) and sends it to the client. The server process keeps running. This is by design — a single bad tool call should never bring down the server.
🎉
Score: 5/5
Perfect score! You're ready for Day 4.
← Previous Day
Day 2: JSON-RPC 2.0
The wire protocol deep dive
Next Day →
Day 4: Tools in Depth
Advanced tool patterns, streaming, and error handling