The Model Context Protocol (MCP) is quickly becoming the standard for connecting AI models to external tools and data sources. Adopted by Anthropic, OpenAI, and Google, MCP lets you build servers that any compatible AI client can use.
In this hands-on tutorial, you'll build a working MCP server from scratch. We'll start simple and progressively add features until you have a fully functional server that works with Claude Desktop, ChatGPT, and other MCP clients.
What You'll Build
By the end of this tutorial, you'll have an MCP server that:
- Exposes tools that AI models can call
- Provides resources for data access
- Works with Claude Desktop via stdio transport
- Works with ChatGPT via HTTP transport
- Includes proper error handling and validation
We'll build a simple weather server as our example—it's straightforward enough to understand but demonstrates all the core MCP concepts.
Prerequisites
Before starting, make sure you have:
- Node.js 18+ — Check with
node --version - npm — Comes with Node.js
- A code editor — VS Code recommended
- Claude Desktop (optional) — For testing with Claude
Basic familiarity with TypeScript and command-line tools is helpful but not required.
Understanding MCP Concepts
Before we code, let's understand the three main things an MCP server can expose:
Tools
Tools are functions that the AI can call to perform actions. Each tool has a name, description, input schema (what parameters it accepts), and a handler function that executes the action.
Example: A get_weather tool that accepts a city name and returns weather data.
Resources
Resources provide data that the AI can read. Unlike tools, resources don't perform actions—they just return information. Resources have a URI scheme and can be static or dynamic.
Example: A weather://cities resource that returns a list of supported cities.
Prompts
Prompts are pre-defined templates that help the AI use your server effectively. They're optional but can improve the user experience.
Example: A prompt template for "Get weather forecast for the next week."
MCP supports multiple transport protocols. stdio (standard input/output) is used for local servers like Claude Desktop. HTTP (Streamable HTTP or SSE) is used for remote servers like ChatGPT apps. We'll cover both in this tutorial.
Project Setup
Let's create a new project and install dependencies:
# Create project directory
mkdir mcp-weather-server
cd mcp-weather-server
# Initialize npm project
npm init -y
# Install dependencies
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node
Create a tsconfig.json for TypeScript:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}
Update your package.json:
{
"name": "mcp-weather-server",
"version": "1.0.0",
"type": "module",
"bin": {
"mcp-weather": "./build/index.js"
},
"scripts": {
"build": "tsc && chmod +x build/index.js",
"dev": "tsc --watch"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.20.0",
"zod": "^3.25.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.0.0"
}
}
Create the source directory:
mkdir src
touch src/index.ts
Creating Your First Tool
Now let's write our MCP server. Open src/index.ts and add:
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
// Create the MCP server
const server = new McpServer({
name: "weather-server",
version: "1.0.0",
});
// Mock weather data (in a real app, call an API)
const weatherData: Record<string, { temp: number; conditions: string }> = {
"london": { temp: 12, conditions: "Cloudy" },
"paris": { temp: 15, conditions: "Sunny" },
"tokyo": { temp: 22, conditions: "Clear" },
"new york": { temp: 18, conditions: "Partly cloudy" },
};
// Register the get_weather tool
server.tool(
"get_weather", // Tool name
"Get current weather for a city", // Description
{ // Input schema using Zod
city: z.string().describe("City name (e.g., London, Paris, Tokyo)")
},
async ({ city }) => { // Handler function
const cityLower = city.toLowerCase();
const weather = weatherData[cityLower];
if (!weather) {
return {
content: [{
type: "text",
text: `Weather data not available for "${city}". Available cities: ${Object.keys(weatherData).join(", ")}`
}]
};
}
return {
content: [{
type: "text",
text: `Weather in ${city}: ${weather.temp}°C, ${weather.conditions}`
}]
};
}
);
// Start the server with stdio transport
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Weather MCP server running on stdio");
}
main().catch(console.error);
For stdio-based servers, always use console.error() for logging, not console.log(). Standard output (stdout) is reserved for MCP protocol messages. Writing to stdout will corrupt the communication.
Build the server:
npm run build
Adding Resources
Let's add a resource that lists available cities. Add this before main():
// Register a resource for listing cities
server.resource(
"cities", // Resource name
"weather://cities", // Resource URI
async () => ({ // Handler
contents: [{
uri: "weather://cities",
mimeType: "application/json",
text: JSON.stringify({
cities: Object.keys(weatherData),
description: "List of cities with available weather data"
}, null, 2)
}]
})
);
Now let's add a dynamic resource that provides detailed info for each city:
// Register a resource template for individual cities
server.resource(
"city-weather",
"weather://city/{name}",
async (uri) => {
// Extract city name from URI
const match = uri.href.match(/weather:\/\/city\/(.+)/);
const cityName = match ? decodeURIComponent(match[1]).toLowerCase() : "";
const weather = weatherData[cityName];
if (!weather) {
return {
contents: [{
uri: uri.href,
mimeType: "text/plain",
text: `No data for city: ${cityName}`
}]
};
}
return {
contents: [{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify({
city: cityName,
temperature: weather.temp,
conditions: weather.conditions,
unit: "celsius"
}, null, 2)
}]
};
}
);
Rebuild the server:
npm run build
Testing with MCP Inspector
The MCP Inspector is an official tool for testing MCP servers. Run it against your server:
npx @modelcontextprotocol/inspector node build/index.js
This opens a browser interface where you can:
- Click "Connect" to connect to your server
- Go to the "Tools" tab and click "List Tools"
- Select
get_weather, enter a city name, and click "Run" - Check the "Resources" tab to see your registered resources
If everything works, you should see weather data returned for valid cities.
Connecting to Claude Desktop
To use your server with Claude Desktop, you need to configure it in Claude's settings file.
Find your config file
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json
Create or edit the file:
{
"mcpServers": {
"weather": {
"command": "node",
"args": ["/absolute/path/to/mcp-weather-server/build/index.js"]
}
}
}
Important: Use the absolute path to your built index.js file.
Restart Claude Desktop
After saving the config, fully restart Claude Desktop (not just close the window). You should see your server listed in the tools menu.
Test it
Ask Claude: "What's the weather in London?" Claude should use your MCP tool and return the weather data.
HTTP Transport for ChatGPT
ChatGPT apps use HTTP transport instead of stdio. Let's add support for both. Update your src/index.ts:
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";
import http from "http";
// Create the MCP server
const server = new McpServer({
name: "weather-server",
version: "1.0.0",
});
// ... (keep all the tool and resource definitions from before)
// Check command-line args for transport type
const useHttp = process.argv.includes("--http");
const PORT = parseInt(process.env.PORT || "8787");
async function main() {
if (useHttp) {
// HTTP transport for ChatGPT/remote clients
const httpServer = http.createServer(async (req, res) => {
const url = new URL(req.url || "/", `http://localhost:${PORT}`);
// CORS headers
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, mcp-session-id");
if (req.method === "OPTIONS") {
res.writeHead(204);
res.end();
return;
}
if (url.pathname === "/mcp") {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableJsonResponse: true,
});
await server.connect(transport);
await transport.handleRequest(req, res);
} else {
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("Weather MCP Server - POST to /mcp");
}
});
httpServer.listen(PORT, () => {
console.log(`Weather MCP server running at http://localhost:${PORT}/mcp`);
});
} else {
// Stdio transport for Claude Desktop
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Weather MCP server running on stdio");
}
}
main().catch(console.error);
Now you can run the server in two modes:
# For Claude Desktop (stdio)
node build/index.js
# For ChatGPT (HTTP)
node build/index.js --http
For ChatGPT, you'll need to expose your server via HTTPS. Use ngrok for development:
ngrok http 8787
Then add your ngrok URL as a connector in ChatGPT settings.
Skip the server setup
Building MCP servers requires coding, hosting, and maintenance. With Agentappbuilder, create the same functionality through a visual interface—no code required.
Join the waitlistCommon Errors and Solutions
"Cannot find module" errors
Make sure you've run npm run build after making changes. Check that "type": "module" is in your package.json.
Server not appearing in Claude Desktop
Double-check the absolute path in your config file. Ensure Claude Desktop is fully restarted (not just window closed). Check the Developer Console in Claude for error messages.
Tools not being called
Make sure your tool descriptions are clear and specific. The AI uses these descriptions to decide when to call your tools. Vague descriptions lead to poor tool selection.
CORS errors with HTTP transport
Ensure your server sends proper CORS headers. For ChatGPT, allow https://chatgpt.com as an origin.
JSON parsing errors
If using stdio transport, remember that console.log() corrupts the protocol. Use console.error() for all logging.
Next Steps
Congratulations! You've built a working MCP server. Here's where to go from here:
Add real functionality
Replace the mock weather data with calls to a real weather API like OpenWeatherMap or WeatherAPI.
Add authentication
For production servers, implement OAuth to authenticate users and access their data securely.
Deploy to production
Host your HTTP server on Cloudflare Workers, Fly.io, Vercel, or your preferred platform.
Build a ChatGPT app
Use the OpenAI Apps SDK to add UI widgets and submit your app to the ChatGPT directory.