Module 4: MCP Server in TypeScript
Project and TypeScript SDK Setup
Project and TypeScript SDK Setup
Capsule description
Before writing a single line of MCP logic, you need a project that compiles and runs. This isn't a minor detail — an incorrect setup is the number-one cause of frustration when building MCP servers. A misconfigured import, an incomplete tsconfig.json, or a missing dependency can cost you hours of debugging.
In this capsule you're going to create an MCP project in TypeScript from scratch. You don't copy a template — you understand each file, each configuration, each dependency. When you finish, you'll have a project that compiles cleanly, runs, and is ready to receive tools and resources in the following capsules.
The goal is for this setup to become your personal template. Every time you need a new MCP server, you start from here.
Step 1: Initialize the project
Create the directory
mkdir mcp-server-ts
cd mcp-server-ts
Initialize npm
npm init -y
This generates a basic package.json. Now we're going to configure it correctly.
Configure package.json
Replace the content of the generated package.json with this:
{
"name": "mcp-server-ts",
"version": "1.0.0",
"description": "MCP Server in TypeScript with tools and resources",
"type": "module",
"main": "build/index.js",
"bin": {
"mcp-server-ts": "build/index.js"
},
"scripts": {
"build": "tsc",
"start": "node build/index.js",
"dev": "tsc --watch",
"inspect": "npm run build && npx @modelcontextprotocol/inspector node build/index.js"
},
"keywords": ["mcp", "model-context-protocol", "claude-code"],
"license": "MIT"
}
Each field matters:
| Field | Why |
|---|---|
"type": "module" | Enables ES modules (import/export instead of require) — the MCP SDK requires it |
"main" | Entry point when someone imports your package |
"bin" | Lets you run your server as a CLI command |
"scripts.build" | Compiles TypeScript to JavaScript |
"scripts.dev" | Recompiles automatically when you change code |
"scripts.inspect" | Compiles and opens MCP Inspector in a single command |
Why "type": "module" is mandatory
The MCP SDK uses ES modules internally. Without "type": "module", Node.js treats the files as CommonJS and the SDK imports fail:
# ❌ Without "type": "module"
Error [ERR_REQUIRE_ESM]: require() of ES Module
.../node_modules/@modelcontextprotocol/sdk/dist/esm/server/mcp.js
# ✅ With "type": "module"
# Everything works correctly
Step 2: Install dependencies
Production dependencies
npm install @modelcontextprotocol/sdk zod
| Package | Version | Purpose |
|---|---|---|
@modelcontextprotocol/sdk | latest | Official MCP SDK for TypeScript |
zod | latest | Schema validation for tools |
Development dependencies
npm install -D typescript @types/node
| Package | Version | Purpose |
|---|---|---|
typescript | latest | TypeScript compiler |
@types/node | latest | Node.js types (fs, path, process, etc.) |
Verify the installation
npx tsc --version
# Should show something like: Version 5.x.x
If you see the version, the dependencies are correct.
Step 3: Configure TypeScript
Create the tsconfig.json file in the project's root:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "build"]
}
Each option explained
| Option | Value | Why |
|---|---|---|
target | ES2022 | Generates modern JavaScript — supports top-level await, structuredClone |
module | Node16 | Correct module system for Node.js with ES modules |
moduleResolution | Node16 | Import resolution compatible with "type": "module" |
outDir | ./build | Output directory for the compiled JavaScript |
rootDir | ./src | Root directory of the TypeScript source code |
strict | true | Enables all the strict type checks |
esModuleInterop | true | Lets you import CommonJS modules with ES module syntax |
skipLibCheck | true | Doesn't check types in node_modules — speeds up compilation |
declaration | true | Generates .d.ts files — useful if you publish your server as a package |
sourceMap | true | Generates source maps for debugging |
Why strict: true is not optional
TypeScript's strict mode activates checks that prevent real bugs in MCP servers:
// strict: true catches this:
// 1. Possibly undefined parameters
function handleTool(args: { name?: string }) {
console.log(args.name.toUpperCase());
// ^^^^ Error: 'name' is possibly undefined
}
// 2. Implicit returns
async function getResource(): Promise<string> {
const data = await fetchData();
// Error: Not all code paths return a value
// (forces you to handle all cases)
}
// 3. Untyped variables
const result = JSON.parse(data);
// ^^^^^^ Type: any — strict forces you to type it
In an MCP server where inputs come from a language model and outputs go to a strict protocol, each of these checks prevents a potential bug in production.
The most common error with imports
With module: "Node16" and "type": "module", imports of local files need the .js extension (yes, .js, not .ts):
// ❌ This does NOT work
import { helper } from "./utils";
import { helper } from "./utils.ts";
// ✅ This DOES work
import { helper } from "./utils.js";
It seems counterintuitive — you're writing TypeScript but importing with .js. The reason is that TypeScript compiles .ts to .js, and Node.js needs the extension of the compiled file to resolve the import. It's a quirk of the ecosystem, not a bug.
Step 4: Create the folder structure
mkdir -p src
Minimal structure
mcp-server-ts/
├── package.json
├── tsconfig.json
├── node_modules/
└── src/
└── index.ts ← Server entry point
Recommended structure for servers with multiple tools
When your server grows, organize the code like this:
mcp-server-ts/
├── package.json
├── tsconfig.json
├── node_modules/
└── src/
├── index.ts ← Entry point: creates server, registers, connects
├── tools/
│ ├── create-file.ts
│ ├── search-files.ts
│ └── index.ts ← Re-exports all the tools
├── resources/
│ ├── project-structure.ts
│ ├── config.ts
│ └── index.ts ← Re-exports all the resources
└── utils/
├── validation.ts
└── filesystem.ts
For this module, we start with the minimal structure (everything in index.ts) and refactor it in the capsule 06 project.
Step 5: Create the entry point
Create the src/index.ts file:
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
const server = new McpServer({
name: "mcp-server-ts",
version: "1.0.0",
});
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("MCP Server TypeScript running on stdio");
}
main().catch((error) => {
console.error("Fatal error starting server:", error);
process.exit(1);
});
Code breakdown
#!/usr/bin/env node — The shebang. It lets you run the file directly as ./build/index.js if you mark it as executable. Necessary if you define the bin field in package.json.
McpServer — The SDK's main class. It takes a name (identifier for the host) and a version (useful for debugging and compatibility).
StdioServerTransport — The transport that uses stdin/stdout to communicate. It's the default transport for local development and for Claude Code.
console.error — Notice that we use console.error, not console.log. In a stdio server, stdout is reserved for MCP protocol communication. Any output you write to stdout breaks the protocol. Logs always go to stderr.
process.exit(1) — If the server can't start, it exits with code 1 so the host knows something failed.
Step 6: Compile and verify
Compile
npm run build
Expected result:
(no output = success)
TypeScript only prints messages when there are errors. No output means it compiled correctly.
Verify the build
ls build/
# index.js index.js.map index.d.ts index.d.ts.map
You should see 4 files:
index.js— The compiled JavaScript (what Node.js runs)index.js.map— Source map for debuggingindex.d.ts— Type declarationsindex.d.ts.map— Declaration source map
Run
node build/index.js
Expected result:
MCP Server TypeScript running on stdio
The server starts, prints to stderr, and waits for input on stdin. Press Ctrl+C to exit.
Test with MCP Inspector
npm run inspect
This compiles and opens MCP Inspector. You should see your server connected but without tools or resources (we'll add them in the following capsules).
Anatomy of the SDK: the pieces you'll use
Before moving on to implement tools and resources, you need a mental map of the SDK:
Main imports
// The server
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
// Transports
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
// Resource templates
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
// Validation
import { z } from "zod";
The McpServer class
McpServer is your entry point. It has 3 main methods to register primitives:
const server = new McpServer({ name: "...", version: "..." });
// Register a tool
server.tool(name, description, schema, handler);
// Register a resource
server.resource(name, uri_or_template, metadata, handler);
// Register a prompt
server.prompt(name, description, schema, handler);
// Connect a transport
await server.connect(transport);
Zod's role
Zod isn't an implementation detail — it's the contract between your server and the model. When you define a Zod schema, you're saying:
- To the model: "These are the parameters I accept, with these types and constraints"
- To the runtime: "Validate the inputs before they reach my handler"
- To the developer: "This is the TypeScript type of the handler's arguments"
// This Zod schema...
{
filePath: z.string().min(1).describe("Path of the file"),
content: z.string().describe("Content"),
overwrite: z.boolean().default(false).describe("Overwrite if it exists"),
}
// ...automatically generates:
// 1. JSON Schema for the MCP protocol
// 2. Runtime validation of the inputs
// 3. Inferred TypeScript types for the handler
You'll see Zod in depth in capsule 03. For now, understand that it's the glue between your tools and the MCP protocol.
Development workflow
The cycle: edit → compile → test
1. Edit src/index.ts (or files in src/)
2. Compile: npm run build
3. Test: npm run inspect (or connect to Claude Code)
4. Repeat
Automatic compilation
To avoid running npm run build manually every time:
npm run dev
# Equivalent to: tsc --watch
This recompiles automatically every time you save a .ts file. Leave this terminal open while you work.
Connect to Claude Code during development
# Add your server to Claude Code
claude mcp add my-server -s user -- node /absolute/path/to/mcp-server-ts/build/index.js
# Verify the connection
claude
/mcp
# You should see: my-server: connected
Every time you recompile, you need to restart Claude Code so it detects the changes. The stdio transport creates a new instance of the server per session.
Anatomy of a tool (preview)
Before capsule 03, a preview of what a complete tool looks like so you understand where we're headed:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
const server = new McpServer({
name: "my-server",
version: "1.0.0",
});
server.tool(
"greet_user", // tool name
"Generates a personalized greeting for a user", // description
{ // Zod schema
name: z.string().describe("The user's name"),
language: z.enum(["es", "en", "fr"]).default("es").describe("Greeting language"),
},
async ({ name, language }) => { // handler
const greetings = {
es: `¡Hola, ${name}! Bienvenido.`,
en: `Hello, ${name}! Welcome.`,
fr: `Bonjour, ${name}! Bienvenue.`,
};
return {
content: [{
type: "text" as const,
text: greetings[language],
}],
};
}
);
Four arguments: name, description, schema, handler. That's it. Capsule 03 explores this in depth with progressively more complex examples.
Comparison: Manual setup vs Starter templates
Why not use a pre-made template?
Starter templates like create-mcp-server and example repos exist. We don't use them here because:
- Understanding > copying. If you don't understand each file in your project, you can't debug it when it fails.
- Templates get outdated. The SDK evolves fast. A template from 3 months ago may have obsolete dependencies.
- Your personal template is better. After this module, you'll have your own setup that you know 100%.
When you SHOULD use a template
When you already master the manual setup and want speed:
# To quickly create a new server after this module
npx @modelcontextprotocol/create-server my-server
But first, understand what the template generates. That's what you're doing now.
Troubleshooting
"Error: Cannot find module '@modelcontextprotocol/sdk/server/mcp.js'"
Cause: The dependencies weren't installed or the import path is incorrect.
Solution:
rm -rf node_modules package-lock.json
npm install
If it persists, verify that @modelcontextprotocol/sdk is in your package.json's dependencies.
"SyntaxError: Cannot use import statement outside a module"
Cause: "type": "module" is missing in package.json.
Solution:
{
"type": "module"
}
"Error: Unknown file extension '.ts'"
Cause: You're running the .ts file directly instead of the compiled .js.
Solution:
# ❌ Don't run the .ts
node src/index.ts
# ✅ Compile first, run the .js
npm run build
node build/index.js
"TSError: ⨯ Unable to compile TypeScript"
Cause: Type errors in your code.
Solution: Read the error message. The most common ones:
# Error: Argument of type 'string' is not assignable to parameter of type 'number'
# → Check the types of your variables
# Error: Property 'x' does not exist on type 'Y'
# → Check the interface/type you're using
# Error: Cannot find module './utils.js'
# → Create the file or check the path (remember to use .js in imports)
"The server starts but Claude Code doesn't detect it"
Cause: Error in the claude mcp add configuration.
Solution:
# Check the current configuration
claude mcp list
# Remove and re-add with an absolute path
claude mcp remove my-server
claude mcp add my-server -s user -- node $(pwd)/build/index.js
# Restart Claude Code
claude
/mcp
Exercises
Exercise 1: Setup from scratch (Easy)
Create an MCP project called hello-mcp following all the steps in this capsule. Verify that:
- It compiles without errors
- It runs and shows the message in stderr
- It connects to the MCP Inspector
See solution
mkdir hello-mcp && cd hello-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node
package.json — add "type": "module" and the scripts.
tsconfig.json — copy the configuration from this capsule.
mkdir src
src/index.ts:
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
const server = new McpServer({
name: "hello-mcp",
version: "1.0.0",
});
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Hello MCP Server running on stdio");
}
main().catch(console.error);
npm run build # should compile without errors
node build/index.js # should print "Hello MCP Server running on stdio"
npm run inspect # should open MCP Inspector with the server connected
Exercise 2: Add a trivial tool (Easy)
Add an echo tool to your server that takes a message (string) and returns it exactly as received. Verify with MCP Inspector.
See solution
import { z } from "zod";
server.tool(
"echo",
"Returns the message exactly as it was received",
{
message: z.string().describe("Message to repeat"),
},
async ({ message }) => {
return {
content: [{
type: "text" as const,
text: message,
}],
};
}
);
Compile with npm run build, open the Inspector with npm run inspect, go to the Tools tab, and test with { "message": "Hello MCP!" }.
Exercise 3: Experiment with compilation errors (Medium)
Introduce these errors intentionally in your src/index.ts and observe what the compiler says. Then fix them:
- Change the import from
.jsto.ts - Remove
"type": "module"frompackage.json - Use
console.loginstead ofconsole.errorand observe what happens when connecting with the Inspector
See solution
- Import with
.ts:
# Error: An import path can only end with a '.ts' extension when 'allowImportingTsExtensions' is enabled.
# Solution: use .js in the imports of local files
- Without
"type": "module":
# Error at runtime: SyntaxError: Cannot use import statement outside a module
# Solution: add "type": "module" in package.json
console.login stdio:
# The Inspector can't connect or shows parsing errors
# Because console.log writes to stdout, which is the MCP protocol's channel
# Any text that isn't valid JSON-RPC breaks the communication
# Solution: always use console.error for logs
Exercise 4: Modular project structure (Medium)
Refactor your server so the echo tool lives in its own file src/tools/echo.ts. Export a registerEchoTool(server: McpServer) function that registers the tool.
See solution
src/tools/echo.ts:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
export function registerEchoTool(server: McpServer) {
server.tool(
"echo",
"Returns the message exactly as it was received",
{
message: z.string().describe("Message to repeat"),
},
async ({ message }) => {
return {
content: [{
type: "text" as const,
text: message,
}],
};
}
);
}
src/index.ts:
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { registerEchoTool } from "./tools/echo.js";
const server = new McpServer({
name: "mcp-server-ts",
version: "1.0.0",
});
registerEchoTool(server);
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("MCP Server running on stdio");
}
main().catch(console.error);
Notice the import with the .js extension: "./tools/echo.js".
mkdir -p src/tools
# Create the files, then:
npm run build
npm run inspect
Exercise 5: Shebang and direct execution (Medium)
Configure your server so it can run directly as a script:
- Verify that the shebang
#!/usr/bin/env nodeis on the first line - Make the file executable
- Run it without an explicit
node
See solution
npm run build
chmod +x build/index.js
./build/index.js
# Should print: MCP Server running on stdio
# This is what Claude Code runs internally when you configure the server
The shebang #!/usr/bin/env node tells the operating system to use Node.js to run the file. Without the shebang, the system tries to run the JavaScript as bash and fails.
Complete setup checklist
Before moving on to the next capsule, verify that you have everything:
-
package.jsonwith"type": "module"and scripts (build,start,dev,inspect) -
tsconfig.jsonwithstrict: true,module: "Node16",outDir: "./build" -
@modelcontextprotocol/sdkandzodinstalled -
typescriptand@types/nodeas devDependencies -
src/index.tswith McpServer and StdioServerTransport -
npm run buildcompiles without errors -
node build/index.jsstarts and shows a message in stderr -
npm run inspectopens MCP Inspector with the server connected -
.gitignoreincludesnode_modules/andbuild/(if you use git)
If everything is green, your project is ready to receive tools and resources.
Summary
In this capsule:
- You created an MCP project in TypeScript from scratch — directory, npm init, dependencies
- You configured
package.jsonwith"type": "module"and development scripts - You configured
tsconfig.jsonwithstrict: trueandmodule: "Node16" - You installed the SDK (
@modelcontextprotocol/sdk) and Zod for validation - You created the entry point (
src/index.ts) with McpServer and StdioServerTransport - You compiled and verified that the server starts correctly
- You understood why each configuration exists —
"type": "module",strict: true,.jsextensions in imports - You prepared the development workflow: edit → compile → test with Inspector
This setup is your template. Every MCP server you create in the future starts from here.
Additional resources
- MCP TypeScript SDK — README - Official SDK instructions
- Zod Documentation - Complete Zod reference
- TypeScript tsconfig Reference - Documentation of each tsconfig option
- Node.js ES Modules - How ES modules work in Node.js
- MCP Inspector - Visual testing tool
- npm package.json Reference - package.json fields explained
Next capsule: Implement Tools — the most important primitive. Progressively complex Zod schemas, error handling, and design patterns for real tools.