๐Ÿ“ฐ News ๐ŸŽ“ Learn ๐Ÿ”จ Tutorials ๐Ÿง  AI Concepts ๐Ÿ“š Courses ๐Ÿ† Certifications ๐Ÿ› ๏ธ AI Tools ๐Ÿ“ก Social Feed โญ GitHub Repos ๐Ÿ”Œ MCP Servers โš™๏ธ Implementations ๐Ÿค– AI Agents ๐Ÿ’ฐ Cost Calculator ๐Ÿ› ๏ธ Stack Builder ๐Ÿ“ˆ Trending Repos ๐Ÿ“ฌ Newsletter

Build Your First MCP Server

โฑ๏ธ 30 minutes ๐Ÿ“‹ 8 steps ๐Ÿ’ป TypeScript / Node.js ๐Ÿ”„ Last updated: June 2026

The Model Context Protocol (MCP) is how AI assistants connect to your data and tools. Instead of waiting for Claude or ChatGPT to add a feature, you build it yourself. In this tutorial, you'll create a custom MCP server that lets Claude read your local notes โ€” and you'll understand exactly how it works.

Table of Contents

What You'll Build Prerequisites Step 1: Understanding MCP Step 2: Project Setup Step 3: Build the Server Step 4: Define Tools Step 5: Connect to Claude Desktop Step 6: Test End-to-End Step 7: Add More Features Next Steps

๐ŸŽฏ What You'll Build

A "Notes MCP Server" that:

By the end, you'll know how to build any MCP server: database connectors, API integrations, file system tools.

๐Ÿ“‹ Prerequisites

Step 1: Understanding MCP

MCP is a protocol โ€” like HTTP, but specifically for AI context. Here's the mental model:

๐Ÿ’ก Think of it like this: Plugins extend a browser. MCP servers extend an AI assistant. Instead of waiting for Anthropic to add a feature, you build the plugin yourself.

Step 2: Project Setup

Create your project:

mkdir notes-mcp-server && cd notes-mcp-server npm init -y npm install @modelcontextprotocol/sdk zod npm install -D typescript @types/node npx tsc --init # Create directories mkdir src notes # Create sample notes echo "# Project Ideas\n\n- AI recipe generator\n- Smart home dashboard" > notes/ideas.md echo "# Meeting Notes\n\nDiscussed Q3 roadmap. Launch date: July 15." > notes/meetings.md

Update tsconfig.json:

{ "compilerOptions": { "target": "ES2022", "module": "Node16", "moduleResolution": "Node16", "outDir": "./dist", "rootDir": "./src", "strict": true, "esModuleInterop": true } }

Add to package.json:

"type": "module", "scripts": { "build": "tsc", "start": "node dist/index.js" }

Step 3: Build the Server

Create src/index.ts:

import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import { promises as fs } from "fs"; import path from "path"; const NOTES_DIR = "./notes"; // Create the MCP server const server = new Server( { name: "notes-server", version: "1.0.0", }, { capabilities: { tools: {}, }, } ); // Define available tools server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: "list_notes", description: "List all available notes", inputSchema: { type: "object", properties: {}, required: [], }, }, { name: "read_note", description: "Read the contents of a specific note", inputSchema: { type: "object", properties: { filename: { type: "string", description: "Name of the note file (e.g., ideas.md)", }, }, required: ["filename"], }, }, { name: "search_notes", description: "Search notes for a keyword", inputSchema: { type: "object", properties: { query: { type: "string", description: "Keyword to search for", }, }, required: ["query"], }, }, { name: "create_note", description: "Create a new note", inputSchema: { type: "object", properties: { filename: { type: "string" }, content: { type: "string" }, }, required: ["filename", "content"], }, }, ], }; }); // Handle tool calls server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; switch (name) { case "list_notes": { const files = await fs.readdir(NOTES_DIR); return { content: [ { type: "text", text: `Available notes:\n${files.map(f => `- ${f}`).join("\n")}`, }, ], }; } case "read_note": { const filepath = path.join(NOTES_DIR, args.filename as string); const content = await fs.readFile(filepath, "utf-8"); return { content: [{ type: "text", text: content }], }; } case "search_notes": { const files = await fs.readdir(NOTES_DIR); const query = (args.query as string).toLowerCase(); const results: string[] = []; for (const file of files) { const content = await fs.readFile(path.join(NOTES_DIR, file), "utf-8"); if (content.toLowerCase().includes(query)) { results.push(`${file}: ${content.substring(0, 200)}...`); } } return { content: [ { type: "text", text: results.length ? `Found in ${results.length} note(s):\n\n${results.join("\n\n")}` : "No matches found.", }, ], }; } case "create_note": { const filepath = path.join(NOTES_DIR, args.filename as string); await fs.writeFile(filepath, args.content as string); return { content: [ { type: "text", text: `Created ${args.filename}` }, ], }; } default: throw new Error(`Unknown tool: ${name}`); } }); // Start the server async function main() { const transport = new StdioServerTransport(); await server.connect(transport); } main().catch(console.error);
๐Ÿ’ก Key concept: MCP servers communicate via stdio (standard input/output). Claude Desktop spawns your server as a child process and talks to it through pipes. This is why the server runs locally and securely.

Step 4: Define Tools

We already defined 4 tools in the code above. Let's understand each:

The inputSchema is crucial โ€” it tells Claude exactly what parameters each tool needs. Without this, Claude can't call your tools correctly.

Step 5: Connect to Claude Desktop

Build your server first:

npm run build

Now tell Claude Desktop about your server. Edit Claude's config:

Add your server:

{ "mcpServers": { "notes": { "command": "node", "args": [ "/ABSOLUTE/PATH/TO/notes-mcp-server/dist/index.js" ] } } }
โš ๏ธ Important: Use the absolute path, not a relative path. Claude Desktop runs from its own directory and won't find relative paths.

Restart Claude Desktop completely (quit and reopen).

Step 6: Test End-to-End

Open Claude Desktop and look for the ๐Ÿ”จ hammer icon in the chat input. Click it โ€” you should see your 4 tools listed.

Try these prompts:

# Should call list_notes "What notes do I have?" # Should call read_note "Read my ideas.md file" # Should call search_notes "Search my notes for anything about meetings" # Should call create_note "Create a new note called todos.md with my shopping list: milk, eggs, bread"
๐ŸŽ‰ Success check: Claude shows which tool it's calling and asks for permission before executing. After approval, you see the result inline in the chat.

Step 7: Add More Features

Extend your server with these ideas:

// Add to ListToolsRequestSchema handler: { name: "delete_note", description: "Delete a note", inputSchema: { type: "object", properties: { filename: { type: "string" }, }, required: ["filename"], }, }, // Add to CallToolRequestSchema handler: case "delete_note": { const filepath = path.join(NOTES_DIR, args.filename as string); await fs.unlink(filepath); return { content: [{ type: "text", text: `Deleted ${args.filename}` }], }; }

Other extensions to try:

โ† All Tutorials Next: Build a RAG System โ†’