๐ MCP ยท Beginner Tutorial
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.
๐ฏ What You'll Build
A "Notes MCP Server" that:
- Reads markdown notes from a local directory
- Lets Claude search through your notes
- Lets Claude create new notes
- Runs locally โ your notes never leave your machine
By the end, you'll know how to build any MCP server: database connectors, API integrations, file system tools.
๐ Prerequisites
- Node.js 18+ installed
- Claude Desktop installed (download here)
- Basic TypeScript/JavaScript knowledge
- A folder of markdown notes (or we'll create sample ones)
Step 1: Understanding MCP
MCP is a protocol โ like HTTP, but specifically for AI context. Here's the mental model:
- Server = Your code that exposes tools/resources
- Client = Claude Desktop (or any MCP-compatible app)
- Tools = Functions the AI can call (read file, search notes)
- Resources = Data the AI can read (your notes, database rows)
๐ก 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:
- list_notes โ No input needed. Returns all filenames.
- read_note โ Takes a filename. Returns file contents.
- search_notes โ Takes a query. Searches all notes for matches.
- create_note โ Takes filename + content. Creates a new note.
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:
- Mac:
~/Library/Application Support/Claude/claude_desktop_config.json
- Windows:
%APPDATA%\Claude\claude_desktop_config.json
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:
- Append to note โ Add content without overwriting
- List by date โ Sort notes by modification time
- Tag system โ Parse #tags from note content
- Sync to cloud โ Add a tool that syncs to GitHub Gist or S3
๐ Next Steps
- Connect to a real database โ Replace file storage with SQLite or PostgreSQL
- Add resources โ Expose your notes as resources (read-only data) in addition to tools
- Share your server โ Package it with npx so others can run
npx your-server
- Explore the ecosystem โ Check out our MCP server directory for inspiration
๐ฅ Pro tip: The most valuable MCP servers solve a problem you have. Build one for your company's internal API, your personal knowledge base, or your development workflow.