TL;DR — Key Takeaways
- Install:
npm install @modelcontextprotocol/sdk - Define tools with name, description, inputSchema, and handler function
- Test locally: add to
.claude/settings.json→ restart Claude Code - Python alternative:
pip install mcp(same concepts)
What You're Actually Building
An MCP (Model Context Protocol) server is a standalone process that exposes tools, resources, and prompts to AI clients like Claude Code via a standardized protocol. When Claude Code connects to your server, it can call your tools exactly as it calls its built-in tools — read a file, query a database, trigger an API call, or run any custom logic you've implemented.
This guide walks through building a working MCP server in TypeScript from scratch: project setup, defining tools with the SDK, testing locally with Claude Code, and the patterns that make production MCP servers reliable.
Prerequisites
- Node.js 18 or later
- npm or pnpm
- Basic TypeScript familiarity
- Claude Code installed (
npm install -g @anthropic-ai/claude-code)
The official MCP TypeScript SDK handles all protocol communication — you implement the logic, not the wire protocol.
Step 1: Project Setup
mkdir my-mcp-server && cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk
npm install -D typescript @types/node tsx
Create a tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"strict": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}
Add build scripts to package.json:
{
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx src/index.ts"
},
"type": "module"
}
Step 2: Create the Server Entry Point
Create src/index.ts:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({
name: "my-mcp-server",
version: "1.0.0",
});
// Tools will be added here
const transport = new StdioServerTransport();
await server.connect(transport);
This creates an MCP server that communicates via stdio — the standard transport for local MCP servers used with Claude Code. The server name and version are reported to clients.
Step 3: Define Your First Tool
Tools are functions the AI can call. Each tool has:
name— identifier the AI uses to call itdescription— what the AI sees when deciding whether to use itinputSchema— Zod schema defining the parameters- Handler function — your implementation
server.tool(
"get_project_stats",
"Get statistics about the current project: file count, total lines, languages detected",
{
directory: z.string().describe("The directory to analyze"),
},
async ({ directory }) => {
// Your implementation
const stats = await analyzeDirectory(directory);
return {
content: [
{
type: "text",
text: JSON.stringify(stats, null, 2),
},
],
};
}
);
The description is critical — it's what Claude reads when deciding whether to call your tool. Write it as a clear action statement: "Get X from Y" or "Search Z for W". Vague descriptions lead to the tool being misused or ignored.
Tool response format: Tools return a content array. Each item is either:
{ type: "text", text: string }— plain text or JSON stringified{ type: "image", data: string, mimeType: string }— base64-encoded image{ type: "resource", resource: {...} }— a resource reference
Step 4: A Complete Working Example
Here's a practical MCP server that provides file system utilities beyond what Claude Code's built-in tools support — useful for teaching the pattern:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import fs from "fs/promises";
import path from "path";
const server = new McpServer({
name: "project-utils",
version: "1.0.0",
});
// Tool 1: Count lines in a file
server.tool(
"count_lines",
"Count the number of lines in a file",
{ filePath: z.string().describe("Absolute or relative path to the file") },
async ({ filePath }) => {
const content = await fs.readFile(filePath, "utf-8");
const lines = content.split("\n").length;
return {
content: [{ type: "text", text: `${lines} lines in ${path.basename(filePath)}` }],
};
}
);
// Tool 2: Find files matching a pattern
server.tool(
"find_files",
"Recursively find files matching a glob pattern in a directory",
{
directory: z.string().describe("Root directory to search"),
pattern: z.string().describe("Glob pattern, e.g. '*.ts' or 'test/**/*.spec.ts'"),
},
async ({ directory, pattern }) => {
const { glob } = await import("glob");
const files = await glob(pattern, { cwd: directory, absolute: true });
return {
content: [{ type: "text", text: files.join("\n") || "No files found" }],
};
}
);
const transport = new StdioServerTransport();
await server.connect(transport);
Step 5: Exposing Resources
Resources expose data that Claude can read, similar to files. Unlike tools (which take action), resources are read-only data sources:
server.resource(
"project-decisions",
"file:///project/decisions",
async (uri) => {
const content = await fs.readFile("./CLAUDE.md", "utf-8");
return {
contents: [
{
uri: uri.href,
mimeType: "text/markdown",
text: content,
},
],
};
}
);
Resources are useful for exposing structured data (database schemas, configuration, documentation) that the AI should be able to read without you specifying a file path each time.
Step 6: Testing Locally with Claude Code
Build your server and add it to Claude Code's configuration in .claude/settings.json:
{
"mcpServers": {
"project-utils": {
"command": "node",
"args": ["/absolute/path/to/my-mcp-server/dist/index.js"]
}
}
}
For development with tsx (no compilation step):
{
"mcpServers": {
"project-utils": {
"command": "npx",
"args": ["tsx", "/absolute/path/to/my-mcp-server/src/index.ts"]
}
}
}
Restart Claude Code and verify the server is connected:
/mcp
You should see your server listed with its tools. Ask Claude to use one:
Use the count_lines tool to count lines in src/index.ts
Step 7: Error Handling
MCP tools should handle errors gracefully. Unhandled exceptions in a tool crash the server process and disconnect from Claude Code. Use try/catch:
server.tool(
"read_database",
"Query the project database and return results as JSON",
{ query: z.string().describe("SQL SELECT query to execute") },
async ({ query }) => {
try {
const results = await db.query(query);
return {
content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
};
} catch (error) {
return {
content: [{ type: "text", text: `Database error: ${error instanceof Error ? error.message : String(error)}` }],
isError: true,
};
}
}
);
The isError: true flag tells the MCP client the tool encountered an error — Claude Code will show this differently from a successful response and adapt its next action accordingly.
Environment Variables and Configuration
MCP servers can receive environment variables from their mcpServers configuration:
{
"mcpServers": {
"my-server": {
"command": "node",
"args": ["dist/index.js"],
"env": {
"DATABASE_URL": "postgresql://localhost/myapp",
"API_KEY": "sk-..."
}
}
}
}
In your server, access them via process.env:
const dbUrl = process.env.DATABASE_URL;
if (!dbUrl) throw new Error("DATABASE_URL is required");
Never hardcode credentials in server code — always use environment variables and document required variables in your README.
Python Alternative
If TypeScript isn't your preference, the Python SDK works identically:
pip install mcp
import mcp.server.stdio
import mcp.types as types
from mcp.server import Server
app = Server("my-mcp-server")
@app.list_tools()
async def list_tools():
return [
types.Tool(
name="count_lines",
description="Count lines in a file",
inputSchema={"type": "object", "properties": {"path": {"type": "string"}}},
)
]
@app.call_tool()
async def call_tool(name, arguments):
if name == "count_lines":
with open(arguments["path"]) as f:
lines = len(f.readlines())
return [types.TextContent(type="text", text=str(lines))]
async def main():
async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
await app.run(read_stream, write_stream, app.create_initialization_options())
The concepts are identical — the SDK handles the protocol, you implement the logic.
Key Takeaways
- MCP servers are Node.js (or Python) processes that expose tools, resources, and prompts via the MCP protocol
- Install
@modelcontextprotocol/sdkand useMcpServerwithStdioServerTransportfor local use with Claude Code - Each tool needs: name, description (what the AI reads to decide), inputSchema (Zod), and an async handler
- Add to Claude Code via
.claude/settings.jsonundermcpServers; verify with/mcp - Handle errors with try/catch and
isError: true— unhandled exceptions crash the server - Use environment variables for credentials; never hardcode in server code
- Python SDK (
pip install mcp) works with the same concepts if you prefer Python
Related: Model Context Protocol (MCP): The Complete Developer Guide · MCP Server Examples and Use Cases · Claude Code MCP Servers: What They Are & How to Use