How to Build Your First MCP App (From Zero to Working Tool in 30 Minutes)
A no-nonsense walkthrough to building a real MCP server with TypeScript — and connecting it to Claude Desktop

MCP (Model Context Protocol) is the open standard that lets AI models like Claude reach outside the chat window and interact with your tools, files, and APIs — without any custom wrapper code on the model side. Think of it as USB-C for AI: one protocol, plug anything in. If you've been wondering why every serious AI integration in 2025 mentions MCP, this is why.
In this post you'll build a working MCP server from scratch. You'll write the code, plug it into Claude Desktop, and ask Claude to read files from your machine. Total time: under 30 minutes.
What You'll Build
A file-reader MCP server — a small TypeScript process that exposes two tools to Claude:
read_file— reads the contents of any file path you give itlist_directory— lists files in a folder
Once connected, you can ask Claude: "Read my ~/projects/notes.txt and summarise it" — and it will. No API call gymnastics, no LangChain chains, no prompt hacks. Claude calls your tool directly.
Prerequisites
- Node.js 18+ (
node --versionshould say v18 or higher) - Claude Desktop installed (download here)
- Basic TypeScript comfort (you don't need to be an expert)
That's it. No cloud account, no paid API key for this tutorial.
Step 1 — Bootstrap the Project
mkdir file-reader-mcp && cd file-reader-mcp
npm init -y
npm install @modelcontextprotocol/sdk
npm install -D typescript @types/node tsx
npx tsc --init --target ES2022 --module Node16 --moduleResolution Node16 --outDir dist --rootDir src --strict
mkdir src
Your package.json scripts section should look like:
"scripts": {
"build": "tsc",
"dev": "tsx src/index.ts",
"start": "node dist/index.js"
}
Step 2 — Write the Server
Create src/index.ts:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import * as fs from "fs";
import * as path from "path";
// Create the MCP server
const server = new McpServer({
name: "file-reader",
version: "1.0.0",
});
// Tool 1: read_file
server.tool(
"read_file",
"Read the contents of a file from the local filesystem",
{
file_path: z.string().describe("Absolute or relative path to the file"),
encoding: z
.enum(["utf8", "base64"])
.optional()
.default("utf8")
.describe("File encoding (default: utf8)"),
},
async ({ file_path, encoding }) => {
const resolved = path.resolve(file_path);
if (!fs.existsSync(resolved)) {
return {
content: [{ type: "text", text: `Error: File not found: ${resolved}` }],
isError: true,
};
}
const stat = fs.statSync(resolved);
if (stat.size > 1_000_000) {
return {
content: [
{
type: "text",
text: `Error: File too large (${(stat.size / 1024).toFixed(1)} KB). Max 1 MB.`,
},
],
isError: true,
};
}
const content = fs.readFileSync(resolved, encoding as BufferEncoding);
return {
content: [{ type: "text", text: content }],
};
}
);
// Tool 2: list_directory
server.tool(
"list_directory",
"List files and folders in a directory",
{
dir_path: z
.string()
.describe("Path to the directory to list"),
show_hidden: z
.boolean()
.optional()
.default(false)
.describe("Include hidden files (dotfiles)"),
},
async ({ dir_path, show_hidden }) => {
const resolved = path.resolve(dir_path);
if (!fs.existsSync(resolved)) {
return {
content: [
{ type: "text", text: `Error: Directory not found: ${resolved}` },
],
isError: true,
};
}
const entries = fs.readdirSync(resolved, { withFileTypes: true });
const filtered = show_hidden
? entries
: entries.filter((e) => !e.name.startsWith("."));
const lines = filtered.map((e) => {
const type = e.isDirectory() ? "📁" : "📄";
return `${type} ${e.name}`;
});
return {
content: [
{
type: "text",
text: `Contents of ${resolved}:\n\n${lines.join("\n")}`,
},
],
};
}
);
// Start the server over stdio (how Claude Desktop communicates)
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("File Reader MCP server running on stdio");
}
main().catch((err) => {
console.error("Fatal error:", err);
process.exit(1);
});
Note on
zod: The MCP SDK uses Zod for schema validation. Install it:npm install zod
Step 3 — Build It
npm install zod
npm run build
You should see a dist/ folder with compiled JS. Test it manually:
echo '{}' | node dist/index.js
# Should print: File Reader MCP server running on stdio
If you see the message, the server starts correctly. Ctrl-C to exit.
Step 4 — Connect to Claude Desktop
Claude Desktop reads MCP server configs from a JSON file. Open (or create) the config:
macOS:
~/Library/Application Support/Claude/claude_desktop_config.json
Windows:
%APPDATA%\Claude\claude_desktop_config.json
Add your server to the mcpServers object:
{
"mcpServers": {
"file-reader": {
"command": "node",
"args": ["/absolute/path/to/file-reader-mcp/dist/index.js"]
}
}
}
Replace /absolute/path/to/file-reader-mcp with your actual project path. Get it fast with:
# Run this from your project directory
echo "$(pwd)/dist/index.js"
Restart Claude Desktop — it only reads the config on startup.
Step 5 — Test It
Open Claude Desktop. In the bottom-left of the chat window you should see a small tools icon (🔧) — click it and confirm file-reader appears in the list with read_file and list_directory tools.
Now try it:
"Use read_file to read /etc/hosts and tell me what's in it"
Claude will call your tool, get the file contents, and summarise them. You'll see the tool call rendered inline in the conversation.
Try the directory listing:
"List the files in my Downloads folder at ~/Downloads"
If something doesn't work, check Claude Desktop logs:
# macOS
tail -f ~/Library/Logs/Claude/mcp*.log
Common issues:
- Server not appearing: Incorrect path in
claude_desktop_config.json— use absolute paths only - Tool call fails: Check the log for the actual error from your server (stderr shows up in the log)
- Permission denied: The server runs as your user, so file permissions apply normally
What's Next
You've got a working MCP server. Here are five ways to push it further:
- Add a
write_filetool — let Claude create or edit files (add a confirmation prompt pattern for safety) - Search tool — implement grep-style search across a directory tree
- Watch mode — expose a tool that tails a log file and returns the last N lines
- Database access — swap the filesystem for a SQLite query tool using
better-sqlite3 - HTTP requests — add a
fetch_urltool so Claude can pull external data on demand
Each of these follows the exact same pattern: define the tool with a Zod schema, implement the handler, return content with type: "text". The SDK handles everything else.
For real-world inspiration — and to skip building from scratch — check what the community has already shipped.
Browse 27 production MCP apps at getmcpapps.com
From database connectors to browser automation to custom API bridges — if you want to see how production MCP servers are structured before you build your own, that's the place to start.
Written by The Plug — the MCPHub team at getmcpapps.com