Building Your First MCP Server
Step-by-step guide to building an MCP server in TypeScript - from project setup to tool definitions, resource handling, testing, and deployment.
Building Your First MCP Server
MCP (Model Context Protocol) is the standard way to give AI agents access to external tools and data. Instead of building custom integrations for every AI client, you build one MCP server that works with Claude Code, Cursor, Windsurf, and any other MCP-compatible tool.
This guide takes you from zero to a working MCP server in TypeScript. You will build a server that exposes tools, serves resources, and handles prompt templates. By the end, you will have a server you can plug into your AI coding workflow.
Prerequisites
Before you start, make sure you have:
- Node.js 18+ installed (
node --versionto check) - npm or another package manager (pnpm, yarn, bun all work)
- An MCP-compatible client to test with (Claude Code is recommended)
- Basic TypeScript knowledge (types, async/await, imports)
No prior MCP experience is required. This guide explains every concept from scratch.
What is an MCP server?
An MCP server is a program that exposes three types of capabilities to AI clients:
-
Tools - Functions the AI can call. Think of these as API endpoints the model invokes when it needs to take an action: read a database, send an email, create a file, query an API. Tools are the most commonly used MCP capability.
-
Resources - Data the AI can read. Resources provide context to the model, like files, database records, or API responses. They are read-only and let the model access information without calling a tool.
-
Prompt templates - Reusable prompt structures with placeholders. These help standardize how the AI interacts with your domain by providing pre-built prompts that users can fill in.
The server communicates with clients over one of two transports:
- Stdio - The server reads from stdin and writes to stdout. The client spawns the server as a child process. This is the simplest transport and the one most clients use.
- HTTP/SSE - The server runs as an HTTP service. Clients connect via Server-Sent Events. This is useful for remote servers, shared team servers, and production deployments.
For this guide, we will use stdio transport since it is simpler and works with the widest range of clients.
Project setup
Create a new directory and initialize the project:
mkdir my-mcp-server
cd my-mcp-server
npm init -y
Install the MCP SDK and TypeScript:
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node
The @modelcontextprotocol/sdk package is the official TypeScript SDK for building MCP servers. zod is used for defining input schemas for your tools.
Create a tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true
},
"include": ["src/**/*"]
}
Update your package.json to include the build script and set the module type:
{
"name": "my-mcp-server",
"version": "1.0.0",
"type": "module",
"main": "dist/index.js",
"bin": {
"my-mcp-server": "dist/index.js"
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"start": "node dist/index.js"
}
}
Create the source directory:
mkdir src
Building the server
Step 1: Create the server entry point
Create src/index.ts. This is the main file that sets up the MCP server, defines its capabilities, and connects the transport.
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
// Create the MCP server instance
const server = new McpServer({
name: "my-mcp-server",
version: "1.0.0",
});
// We will add tools, resources, and prompts here in the next steps
// Connect using stdio transport
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("MCP server running on stdio");
}
main().catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});
Note that we log to stderr (via console.error), not stdout. This is important because stdout is reserved for the MCP protocol messages. Any logging you do must go to stderr.
Step 2: Add your first tool
Tools are the core of most MCP servers. Let's add a simple tool that fetches the current weather for a city. This demonstrates the pattern you will use for all tools: define a name, description, input schema, and handler function.
Add this between the server creation and the main() function:
import { z } from "zod";
server.tool(
"get_weather",
"Get the current weather for a city. Returns temperature, conditions, and humidity.",
{
city: z.string().describe("The city name, e.g. 'San Francisco'"),
units: z
.enum(["celsius", "fahrenheit"])
.default("celsius")
.describe("Temperature units"),
},
async ({ city, units }) => {
// In a real server, you would call a weather API here.
// For this example, we return mock data.
const temp = units === "celsius" ? 22 : 72;
const unitLabel = units === "celsius" ? "C" : "F";
return {
content: [
{
type: "text",
text: `Weather in ${city}: ${temp} degrees ${unitLabel}, partly cloudy, 65% humidity.`,
},
],
};
}
);
Let's break down the four arguments to server.tool():
-
Name (
"get_weather") - A unique identifier for the tool. AI clients use this name to call the tool. Use snake_case by convention. -
Description - A natural language explanation of what the tool does. The AI model reads this description to decide when to use the tool. Be specific about inputs, outputs, and when the tool is appropriate.
-
Input schema - A Zod schema defining the parameters the tool accepts. The SDK validates inputs against this schema before calling your handler. Zod's
.describe()method adds parameter-level descriptions that help the AI fill in the right values. -
Handler - An async function that receives the validated inputs and returns a result. The result must include a
contentarray with text or image blocks.
Step 3: Add a tool with error handling
Real tools need error handling. Here is a more realistic tool that reads a file from disk:
import fs from "fs/promises";
import path from "path";
server.tool(
"read_file",
"Read the contents of a file at the given path. Returns the file content as text. Fails if the file does not exist or cannot be read.",
{
filePath: z.string().describe("Absolute or relative path to the file"),
},
async ({ filePath }) => {
try {
const resolvedPath = path.resolve(filePath);
const content = await fs.readFile(resolvedPath, "utf-8");
return {
content: [
{
type: "text",
text: content,
},
],
};
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown error";
return {
content: [
{
type: "text",
text: `Error reading file: ${message}`,
},
],
isError: true,
};
}
}
);
Notice the isError: true flag in the error response. This tells the AI client that the tool invocation failed, so the model can adjust its approach (try a different path, ask the user for help, etc.) rather than treating the error message as successful output.
Step 4: Add a tool that calls an external API
Here is a tool that demonstrates calling a real external service - searching a database, calling a REST API, or querying a third-party service:
server.tool(
"search_github_repos",
"Search GitHub repositories by keyword. Returns the top 5 matching repos with name, description, stars, and URL.",
{
query: z.string().describe("Search query for GitHub repositories"),
language: z
.string()
.optional()
.describe("Filter by programming language, e.g. 'typescript'"),
},
async ({ query, language }) => {
const params = new URLSearchParams({
q: language ? `${query} language:${language}` : query,
sort: "stars",
order: "desc",
per_page: "5",
});
const response = await fetch(
`https://api.github.com/search/repositories?${params}`,
{
headers: {
Accept: "application/vnd.github.v3+json",
"User-Agent": "my-mcp-server",
},
}
);
if (!response.ok) {
return {
content: [
{
type: "text",
text: `GitHub API error: ${response.status} ${response.statusText}`,
},
],
isError: true,
};
}
const data = await response.json();
const repos = data.items.map(
(repo: {
full_name: string;
description: string | null;
stargazers_count: number;
html_url: string;
}) => ({
name: repo.full_name,
description: repo.description || "No description",
stars: repo.stargazers_count,
url: repo.html_url,
})
);
return {
content: [
{
type: "text",
text: JSON.stringify(repos, null, 2),
},
],
};
}
);
Step 5: Add resources
Resources provide read-only data to the AI client. They are useful for exposing configuration files, database state, or any data the model might need for context.
server.resource(
"config",
"config://app/settings",
async (uri) => {
// In a real server, read from a config file or database
const config = {
appName: "My Application",
version: "2.1.0",
environment: process.env.NODE_ENV || "development",
features: {
darkMode: true,
notifications: true,
analytics: false,
},
};
return {
contents: [
{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify(config, null, 2),
},
],
};
}
);
The server.resource() method takes three arguments:
- Name - A human-readable name for the resource.
- URI - A unique identifier using a custom scheme (like
config://ordb://). Clients use this URI to request the resource. - Handler - An async function that returns the resource contents. The handler receives the parsed URI object.
You can also add resources with dynamic URIs using templates:
server.resource(
"user-profile",
"users://{userId}/profile",
async (uri) => {
// Extract the userId from the URI
const userId = uri.pathname.split("/")[1];
// Fetch user data (mock example)
const user = {
id: userId,
name: "Jane Developer",
email: "jane@example.com",
role: "admin",
};
return {
contents: [
{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify(user, null, 2),
},
],
};
}
);
Step 6: Add prompt templates
Prompt templates are reusable prompt structures that help standardize how the AI interacts with your domain. They are optional but useful for common workflows.
server.prompt(
"code_review",
"Review code for bugs, security issues, and best practices",
{
code: z.string().describe("The code to review"),
language: z.string().describe("Programming language of the code"),
focus: z
.enum(["bugs", "security", "performance", "all"])
.default("all")
.describe("What to focus the review on"),
},
({ code, language, focus }) => {
const focusInstructions = {
bugs: "Focus specifically on bugs, logic errors, and edge cases that could cause failures.",
security:
"Focus specifically on security vulnerabilities, injection risks, and unsafe patterns.",
performance:
"Focus specifically on performance bottlenecks, unnecessary allocations, and optimization opportunities.",
all: "Review for bugs, security issues, performance problems, and general best practices.",
};
return {
messages: [
{
role: "user",
content: {
type: "text",
text: `Review the following ${language} code.\n\n${focusInstructions[focus]}\n\n\`\`\`${language}\n${code}\n\`\`\``,
},
},
],
};
}
);
Step 7: The complete server
Here is the full src/index.ts with all the pieces assembled:
#!/usr/bin/env node
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: "my-mcp-server",
version: "1.0.0",
});
// --- Tools ---
server.tool(
"get_weather",
"Get the current weather for a city",
{
city: z.string().describe("The city name"),
units: z.enum(["celsius", "fahrenheit"]).default("celsius"),
},
async ({ city, units }) => {
const temp = units === "celsius" ? 22 : 72;
const unitLabel = units === "celsius" ? "C" : "F";
return {
content: [
{
type: "text",
text: `Weather in ${city}: ${temp} degrees ${unitLabel}, partly cloudy, 65% humidity.`,
},
],
};
}
);
server.tool(
"read_file",
"Read the contents of a file at the given path",
{
filePath: z.string().describe("Path to the file"),
},
async ({ filePath }) => {
try {
const content = await fs.readFile(path.resolve(filePath), "utf-8");
return {
content: [{ type: "text", text: content }],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error: ${error instanceof Error ? error.message : "Unknown error"}`,
},
],
isError: true,
};
}
}
);
server.tool(
"search_github_repos",
"Search GitHub repositories by keyword",
{
query: z.string().describe("Search query"),
language: z.string().optional().describe("Filter by language"),
},
async ({ query, language }) => {
const params = new URLSearchParams({
q: language ? `${query} language:${language}` : query,
sort: "stars",
order: "desc",
per_page: "5",
});
const response = await fetch(
`https://api.github.com/search/repositories?${params}`,
{
headers: {
Accept: "application/vnd.github.v3+json",
"User-Agent": "my-mcp-server",
},
}
);
if (!response.ok) {
return {
content: [
{ type: "text", text: `GitHub API error: ${response.status}` },
],
isError: true,
};
}
const data = await response.json();
const repos = data.items.map(
(repo: {
full_name: string;
description: string | null;
stargazers_count: number;
html_url: string;
}) => ({
name: repo.full_name,
description: repo.description || "No description",
stars: repo.stargazers_count,
url: repo.html_url,
})
);
return {
content: [{ type: "text", text: JSON.stringify(repos, null, 2) }],
};
}
);
// --- Resources ---
server.resource("config", "config://app/settings", async (uri) => {
return {
contents: [
{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify(
{
appName: "My Application",
version: "2.1.0",
environment: process.env.NODE_ENV || "development",
},
null,
2
),
},
],
};
});
// --- Prompt Templates ---
server.prompt(
"code_review",
"Review code for bugs, security issues, and best practices",
{
code: z.string().describe("The code to review"),
language: z.string().describe("Programming language"),
},
({ code, language }) => ({
messages: [
{
role: "user",
content: {
type: "text",
text: `Review the following ${language} code for bugs, security issues, and best practices:\n\n\`\`\`${language}\n${code}\n\`\`\``,
},
},
],
})
);
// --- Start ---
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("MCP server running on stdio");
}
main().catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});
Build and test
Build the server
npm run build
This compiles TypeScript to JavaScript in the dist/ directory. Make the output executable:
chmod +x dist/index.js
Test with Claude Code
The fastest way to test your MCP server is with Claude Code. Add it to your project's .mcp.json file:
{
"mcpServers": {
"my-server": {
"command": "node",
"args": ["/absolute/path/to/my-mcp-server/dist/index.js"]
}
}
}
Replace the path with the actual absolute path to your compiled server.
Start Claude Code in the project directory:
claude
Your MCP tools should appear in the tool list. Ask Claude to use one:
use the get_weather tool to check the weather in Tokyo
search GitHub for the top MCP server repositories written in TypeScript
If the tools do not appear, check for errors by running the server manually:
node dist/index.js
Any errors will print to stderr. Common issues:
- Missing
#!/usr/bin/env nodeshebang line - File not executable (run
chmod +x) - Module resolution errors (check
tsconfig.jsonmodule settings) - Missing dependencies (run
npm install)
Test with the MCP Inspector
The MCP Inspector is an official debugging tool that lets you interact with your server directly through a web UI.
npx @modelcontextprotocol/inspector node dist/index.js
This opens a browser window where you can:
- See all registered tools, resources, and prompts
- Call tools with custom inputs and inspect the responses
- Read resources and verify their output
- Test prompt templates with different parameters
The Inspector is invaluable during development. Use it to verify your server works correctly before connecting it to an AI client.
Test with Cursor
Add the server to Cursor's MCP configuration. Open .cursor/mcp.json in your project:
{
"mcpServers": {
"my-server": {
"command": "node",
"args": ["/absolute/path/to/my-mcp-server/dist/index.js"]
}
}
}
Restart Cursor, and the tools will be available in Agent mode.
Adding environment variables
Most real MCP servers need API keys, database URLs, or other configuration. Pass these as environment variables in the MCP config:
{
"mcpServers": {
"my-server": {
"command": "node",
"args": ["/path/to/dist/index.js"],
"env": {
"WEATHER_API_KEY": "your-api-key-here",
"DATABASE_URL": "postgresql://localhost:5432/mydb"
}
}
}
}
Access them in your server code with process.env:
server.tool(
"get_real_weather",
"Get real weather data from the weather API",
{
city: z.string().describe("City name"),
},
async ({ city }) => {
const apiKey = process.env.WEATHER_API_KEY;
if (!apiKey) {
return {
content: [
{ type: "text", text: "Error: WEATHER_API_KEY not configured" },
],
isError: true,
};
}
const response = await fetch(
`https://api.weather.com/v1/current?city=${encodeURIComponent(city)}&key=${apiKey}`
);
const data = await response.json();
return {
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
};
}
);
Publishing and deployment
Publish to npm
If you want others to use your MCP server, publish it to npm:
- Make sure
package.jsonhas thebinfield set - Add a
filesfield to include only the dist directory:
{
"name": "my-mcp-server",
"version": "1.0.0",
"type": "module",
"bin": {
"my-mcp-server": "dist/index.js"
},
"files": ["dist"],
"scripts": {
"build": "tsc",
"prepublishOnly": "npm run build"
}
}
- Build and publish:
npm run build
npm publish
Users can then configure it in their MCP settings with:
{
"mcpServers": {
"my-server": {
"command": "npx",
"args": ["-y", "my-mcp-server"]
}
}
}
The npx -y prefix downloads and runs the package automatically.
Deploy as an HTTP server
For team-wide or remote access, you can serve your MCP server over HTTP instead of stdio. Replace the transport setup:
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import express from "express";
const app = express();
app.use(express.json());
app.post("/mcp", async (req, res) => {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
await server.connect(transport);
await transport.handleRequest(req, res);
});
app.listen(3001, () => {
console.error("MCP HTTP server running on port 3001");
});
Clients connect using the HTTP transport:
{
"mcpServers": {
"my-server": {
"url": "http://localhost:3001/mcp"
}
}
}
Best practices
Write clear tool descriptions
The description is the most important part of a tool definition. The AI model reads it to decide when and how to use the tool. Good descriptions include:
- What the tool does in one sentence
- What inputs are required and what format they should be in
- What the output looks like
- When to use this tool vs another tool
- Any limitations or side effects
Bad: "Search stuff"
Good: "Search GitHub repositories by keyword. Returns the top 5 matching repos with name, description, star count, and URL. Use this when the user asks about open-source projects, libraries, or wants to find code repositories."
Keep tools focused
Each tool should do one thing well. A tool called manage_database that creates tables, runs queries, and manages migrations is hard for the AI to use correctly. Split it into create_table, run_query, and run_migration.
Validate inputs thoroughly
The Zod schema handles basic type validation, but add your own validation for business logic:
server.tool(
"delete_file",
"Delete a file at the given path",
{
filePath: z.string().describe("Path to the file to delete"),
},
async ({ filePath }) => {
const resolved = path.resolve(filePath);
// Safety check: prevent deletion outside the project directory
if (!resolved.startsWith(process.cwd())) {
return {
content: [
{
type: "text",
text: "Error: Cannot delete files outside the project directory",
},
],
isError: true,
};
}
await fs.unlink(resolved);
return {
content: [{ type: "text", text: `Deleted ${resolved}` }],
};
}
);
Return structured data when possible
JSON responses let the AI extract specific fields and use them in follow-up operations. Plain text responses work for simple outputs, but structured data scales better:
// Prefer this
return {
content: [
{
type: "text",
text: JSON.stringify(
{
status: "success",
filesCreated: 3,
outputPath: "/tmp/output",
},
null,
2
),
},
],
};
Handle errors gracefully
Always return a meaningful error message with isError: true rather than throwing an exception. Thrown exceptions crash the tool invocation and give the AI no information about what went wrong. A descriptive error message lets the AI retry with different inputs or ask the user for help.
Next steps
Now that you have a working MCP server, explore these directions:
- Browse existing servers. The MCP servers repository has dozens of production-quality servers you can study and use as references.
- Add authentication. For HTTP-based servers, add API key validation or OAuth to control access.
- Build domain-specific tools. Create servers for your team's internal tools - Jira, Slack, your production database, deployment pipeline, monitoring dashboards.
- Use resource subscriptions. Resources can notify clients when their data changes, enabling real-time context updates.
- Read the MCP specification. The full spec covers advanced features like sampling, logging, and capability negotiation.
MCP is still early but growing fast. Every major AI coding tool now supports it, and the ecosystem of community servers expands weekly. Building your own server is the best way to understand the protocol and create tools that fit your exact workflow.
Technical content at the intersection of AI and development. Building with AI agents, Claude Code, and modern dev tools - then showing you exactly how it works.





