Build Your First MCP Server in 5 Minutes
Create a fully functional MCP server with a custom tool from scratch using mcp-framework. Follow along step-by-step and have a working server in under 5 minutes.
title: "Build Your First MCP Server in 5 Minutes" description: "Create a fully functional MCP server with a custom tool from scratch using mcp-framework. Follow along step-by-step and have a working server in under 5 minutes." order: 3 level: "beginner" duration: "12 min" keywords:
- build MCP server
- first MCP server
- MCP server tutorial
- mcp-framework tutorial
- MCP tool example
- create MCP server
- MCP hello world date: "2026-04-01"
In this lesson, you will build a working MCP server from scratch using mcp-framework. The server will include a custom tool that fetches weather information. By the end, you will have a running MCP server that any MCP client can connect to.
What Are We Building?
We are going to build a simple MCP server called weather-server that exposes a tool for getting weather information. This gives you a hands-on understanding of the complete MCP server lifecycle:
- Scaffold — Create the project with the CLI
- Implement — Write a custom tool with input validation
- Build — Compile TypeScript to JavaScript
- Test — Verify the server works
By the end, you will have a real MCP server that an AI assistant like Claude can use to answer weather-related questions.
Step-by-Step: Creating the Server
Scaffold the Project
Open your terminal and create a new MCP server project:
mcp create weather-server
cd weather-server
npm install
The CLI creates a complete project structure with TypeScript configuration, an example tool, and the necessary dependencies.
Examine the Entry Point
Open src/index.ts — this is your server's entry point:
import { MCPServer } from "mcp-framework";
const server = new MCPServer();
server.start();
That is all it takes to start an MCP server. The MCPServer class handles protocol negotiation, message routing, and capability discovery. Your tools, resources, and prompts are auto-discovered from their directories.
Remove the Example Tool
Delete the generated example tool — we will create our own:
rm src/tools/ExampleTool.ts
Create the Weather Tool
Create a new file at src/tools/GetWeatherTool.ts:
import { MCPTool } from "mcp-framework";
import { z } from "zod";
interface GetWeatherInput {
city: string;
units?: "celsius" | "fahrenheit";
}
class GetWeatherTool extends MCPTool<GetWeatherInput> {
name = "get_weather";
description = "Get the current weather for a specified city";
schema = {
city: {
type: z.string(),
description: "The city name to get weather for",
},
units: {
type: z.enum(["celsius", "fahrenheit"]).optional(),
description:
"Temperature units (celsius or fahrenheit). Defaults to celsius.",
},
};
async execute(input: GetWeatherInput) {
const city = input.city;
const units = input.units || "celsius";
// In a real server, you would call a weather API here.
// For this tutorial, we return simulated data.
const weatherData = {
city: city,
temperature: units === "celsius" ? 22 : 72,
units: units,
condition: "Partly cloudy",
humidity: 65,
windSpeed: "12 km/h",
};
return JSON.stringify(weatherData, null, 2);
}
}
export default GetWeatherTool;
Let's break down what each part does.
Build and Verify
Compile the TypeScript and make sure there are no errors:
npm run build
If the build succeeds, your compiled server is ready in the dist/ directory.
Understanding the Tool Anatomy
Every MCP tool built with mcp-framework follows the same structure. Let's examine each piece of the GetWeatherTool:
The Class Declaration
class GetWeatherTool extends MCPTool<GetWeatherInput> {
Your tool class extends MCPTool with a generic type parameter that defines the input shape. This gives you full type safety in the execute method.
Name and Description
name = "get_weather";
description = "Get the current weather for a specified city";
The name is the identifier that MCP clients use to call your tool. Use snake_case and make it descriptive. The description tells the AI model what the tool does — write it clearly because the AI uses this to decide when to call your tool.
AI models decide whether to use your tool based on its name and description. A vague description like "does weather stuff" will perform poorly. Be specific: "Get the current weather for a specified city" tells the model exactly what the tool does and when to use it.
The Input Schema
schema = {
city: {
type: z.string(),
description: "The city name to get weather for",
},
units: {
type: z.enum(["celsius", "fahrenheit"]).optional(),
description: "Temperature units (celsius or fahrenheit). Defaults to celsius.",
},
};
The schema defines what inputs your tool accepts. mcp-framework uses Zod for schema definition and validation. Each property has:
type— A Zod type that validates the inputdescription— A human-readable explanation (the AI reads this too)
When an MCP client calls your tool, the framework validates the input against this schema before calling execute. Invalid inputs are rejected automatically with helpful error messages.
The Execute Method
async execute(input: GetWeatherInput) {
const city = input.city;
const units = input.units || "celsius";
const weatherData = {
city: city,
temperature: units === "celsius" ? 22 : 72,
units: units,
condition: "Partly cloudy",
humidity: 65,
windSpeed: "12 km/h",
};
return JSON.stringify(weatherData, null, 2);
}
The execute method is where your business logic lives. It receives the validated input and returns a string result. In a production tool, this is where you would call external APIs, query databases, or perform any other action.
The execute method should return a string. If you are returning structured data, serialize it with JSON.stringify(). The MCP client and AI model will parse and interpret the response.
Adding a Second Tool
Let's add another tool to make our server more useful. Create src/tools/GetForecastTool.ts:
import { MCPTool } from "mcp-framework";
import { z } from "zod";
interface GetForecastInput {
city: string;
days: number;
}
class GetForecastTool extends MCPTool<GetForecastInput> {
name = "get_forecast";
description = "Get a multi-day weather forecast for a specified city";
schema = {
city: {
type: z.string(),
description: "The city name to get the forecast for",
},
days: {
type: z.number().min(1).max(7),
description: "Number of days to forecast (1-7)",
},
};
async execute(input: GetForecastInput) {
const forecast = Array.from({ length: input.days }, (_, i) => ({
day: i + 1,
date: new Date(Date.now() + (i + 1) * 86400000)
.toISOString()
.split("T")[0],
high: Math.round(18 + Math.random() * 10),
low: Math.round(10 + Math.random() * 8),
condition: ["Sunny", "Partly cloudy", "Cloudy", "Rain"][
Math.floor(Math.random() * 4)
],
}));
return JSON.stringify(
{
city: input.city,
forecast: forecast,
},
null,
2
);
}
}
export default GetForecastTool;
Notice how the second tool follows the exact same pattern. The auto-discovery system picks it up automatically — no registration code needed.
Building and Testing the Complete Server
Now rebuild with both tools:
npm run build
Your project structure should now look like this:
Testing with MCP Inspector
The MCP ecosystem includes a tool called MCP Inspector that lets you test your server interactively without connecting it to a full AI client:
npx @modelcontextprotocol/inspector node dist/index.js
This opens a web interface where you can:
- See all registered tools, resources, and prompts
- Call tools with custom inputs
- View the raw MCP protocol messages
- Debug issues with your server
During development, use MCP Inspector to test every tool before connecting to Claude Desktop or Cursor. It shows you exactly what the AI client sees and helps you catch issues early.
How Would This Look with the Official SDK?
For comparison, here is the same weather server built with the official @modelcontextprotocol/sdk:
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: "weather-server",
version: "1.0.0",
});
server.tool(
"get_weather",
"Get the current weather for a specified city",
{
city: z.string().describe("The city name to get weather for"),
units: z
.enum(["celsius", "fahrenheit"])
.optional()
.describe("Temperature units. Defaults to celsius."),
},
async ({ city, units }) => {
const u = units || "celsius";
const weatherData = {
city,
temperature: u === "celsius" ? 22 : 72,
units: u,
condition: "Partly cloudy",
humidity: 65,
windSpeed: "12 km/h",
};
return {
content: [
{ type: "text", text: JSON.stringify(weatherData, null, 2) },
],
};
}
);
server.tool(
"get_forecast",
"Get a multi-day weather forecast for a specified city",
{
city: z.string().describe("The city name to get the forecast for"),
days: z.number().min(1).max(7).describe("Number of days to forecast (1-7)"),
},
async ({ city, days }) => {
const forecast = Array.from({ length: days }, (_, i) => ({
day: i + 1,
date: new Date(Date.now() + (i + 1) * 86400000)
.toISOString()
.split("T")[0],
high: Math.round(18 + Math.random() * 10),
low: Math.round(10 + Math.random() * 8),
condition: ["Sunny", "Partly cloudy", "Cloudy", "Rain"][
Math.floor(Math.random() * 4)
],
}));
return {
content: [
{
type: "text",
text: JSON.stringify({ city, forecast }, null, 2),
},
],
};
}
);
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
}
main().catch(console.error);
Both versions produce identical MCP-compliant servers. The mcp-framework version has cleaner separation of concerns — each tool is its own file, easy to test independently and maintain as your server grows.
Common Mistakes to Avoid
Watch out for these frequent mistakes when building your first server:
Using console.log Instead of console.error
// Bad — interferes with MCP protocol messages on stdout
console.log("Debug info");
// Good — writes to stderr, which is separate from the protocol
console.error("Debug info");
Forgetting the Default Export
// Bad — tool will not be auto-discovered
export class MyTool extends MCPTool<MyInput> { ... }
// Good — default export is required for auto-discovery
export default MyTool;
Returning Non-String Values from Execute
// Bad — execute must return a string
async execute(input: MyInput) {
return { result: "data" };
}
// Good — serialize objects to JSON strings
async execute(input: MyInput) {
return JSON.stringify({ result: "data" });
}
Throwing Unhandled Errors
// Bad — unhandled errors crash the server
async execute(input: MyInput) {
const response = await fetch(someUrl);
const data = await response.json();
return JSON.stringify(data);
}
// Good — handle errors gracefully
async execute(input: MyInput) {
try {
const response = await fetch(someUrl);
if (!response.ok) {
return JSON.stringify({
error: `API returned ${response.status}`,
});
}
const data = await response.json();
return JSON.stringify(data);
} catch (error) {
return JSON.stringify({
error: `Failed to fetch data: ${error}`,
});
}
}
Your execute method should never throw unhandled errors. Wrap external calls in try/catch blocks and return meaningful error messages as strings. This gives the AI model useful information about what went wrong, rather than crashing the server.
What You Have Built
Congratulations — you now have a working MCP server with two tools:
get_weather— Returns current weather for any cityget_forecast— Returns a multi-day forecast with configurable duration
The server is fully MCP-compliant and ready to connect to Claude Desktop, Cursor, or any other MCP client.
What Happens When an AI Uses Your Tool?
Here is the flow when Claude uses your get_weather tool:
- Claude Desktop connects to your server via stdio
- Your server reports its capabilities (two tools available)
- A user asks Claude: "What's the weather in Tokyo?"
- Claude decides to call
get_weatherwith{"city": "Tokyo"} - Your server validates the input against the Zod schema
- The
executemethod runs and returns the weather data - Claude receives the result and incorporates it into its response
The entire exchange happens through the MCP protocol — your server does not need to know anything about Claude, and Claude does not need to know anything about your implementation.
Frequently Asked Questions
Now that you have built your first server, let's take a deeper look at tools. Head to Understanding MCP Tools to master the most powerful MCP primitive.