If you have read about Model Context Protocol (MCP) and thought "sounds cool, but how do I actually build one?" — this tutorial is for you. We will go from an empty folder to a working MCP server connected to Claude Desktop, exposing two real tools, in about 20 minutes.
No prior MCP experience needed. You should be comfortable with Node.js basics and have Node 18+ installed.
What we are building
A tiny MCP server called file-helper with two tools:
list_files— list files in a directory the user namesread_file— read the contents of a file
By the end you will be able to ask Claude Desktop "What files are in my Documents folder?" and it will use your server to answer.
Step 1: Project setup
Create a new folder and initialize:
mkdir file-helper-mcp
cd file-helper-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod
What each package does:
@modelcontextprotocol/sdk— the official MCP server library for Nodezod— schema validation for tool inputs (the SDK uses it for both validation and JSON Schema generation)
Edit package.json to enable ES modules:
{
"name": "file-helper-mcp",
"version": "1.0.0",
"type": "module",
"main": "server.js",
"bin": { "file-helper-mcp": "server.js" }
}
The "type": "module" line is what lets us use import syntax.
Step 2: The skeleton
Create server.js:
#!/usr/bin/env node
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
const server = new McpServer({
name: 'file-helper',
version: '1.0.0',
});
// (We will add tools below.)
const transport = new StdioServerTransport();
await server.connect(transport);
That is the minimum — a server that does nothing useful but speaks MCP correctly. Save it and move on.
Step 3: Add the first tool — list_files
Add this above the server.connect() line:
import { readdir, stat } from 'node:fs/promises';
import { resolve } from 'node:path';
import { z } from 'zod';
server.tool(
'list_files',
'List files and folders inside a directory',
{
path: z.string().describe('Absolute or relative directory path, e.g. "/Users/me/Documents"'),
},
async ({ path }) => {
const abs = resolve(path);
const entries = await readdir(abs, { withFileTypes: true });
const lines = entries.map(e => `${e.isDirectory() ? '📁' : '📄'} ${e.name}`);
return {
content: [{
type: 'text',
text: `Contents of ${abs}:\n${lines.join('\n')}`,
}],
};
}
);
Breakdown of the server.tool() arguments:
- Name (
'list_files') — what the LLM will reference - Description — what the LLM uses to decide when to call this tool. Be specific.
- Input schema (Zod object) — auto-converted to JSON Schema and validated on every call
- Handler function — receives validated arguments, returns a content array
The content array can have multiple items of various types (text, image, resource). For now we use plain text.
Step 4: Add the second tool — read_file
import { readFile } from 'node:fs/promises';
server.tool(
'read_file',
'Read the text contents of a file',
{
path: z.string().describe('Path to the file to read'),
maxBytes: z.number().int().positive().optional().describe('Maximum bytes to return (default 50000)'),
},
async ({ path, maxBytes = 50000 }) => {
const abs = resolve(path);
const fileStat = await stat(abs);
if (fileStat.size > maxBytes) {
return {
content: [{ type: 'text', text: `File is ${fileStat.size} bytes; exceeds limit of ${maxBytes}.` }],
isError: true,
};
}
const contents = await readFile(abs, 'utf8');
return { content: [{ type: 'text', text: contents }] };
}
);
Two things worth noticing:
- Optional parameters — Zod's
.optional()makes the field optional in the schema; the LLM may or may not include it. - Error responses — returning
isError: truetells the host the call failed in a recoverable way. The LLM sees the error text and can react (apologize, retry with different args, etc.).
Step 5: Test it locally
Before connecting to Claude, test with the official MCP Inspector. It is a browser UI that connects to your server and lets you click through every tool:
npx @modelcontextprotocol/inspector node server.js
This prints a localhost URL. Open it. You will see your server's name, the tools list (list_files, read_file), and a form to invoke them. Test both tools with real paths.
If something fails, the Inspector shows the exact JSON-RPC messages — invaluable for debugging.
Step 6: Connect to Claude Desktop
Find Claude Desktop's config file:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json
If it does not exist, create it. Add (or merge) this entry:
{
"mcpServers": {
"file-helper": {
"command": "node",
"args": ["/absolute/path/to/file-helper-mcp/server.js"]
}
}
}
Use the absolute path to your server.js. Relative paths will not work — Claude Desktop launches the process from its own working directory.
Save the config and fully quit Claude Desktop (not just close the window). Reopen it.
In a new chat, type something like:
"What files are in my Downloads folder?"
Claude should ask permission to use the list_files tool, then call it and read the response. If you see the tool call indicator (a small icon or expandable card), you are wired up correctly.
Step 7: Distribute via npx
Making your server installable with one command makes it shareable:
Add a shebang line to server.js (already there if you copied above) and ensure package.json has the "bin" field:
{
"bin": { "file-helper-mcp": "server.js" }
}
Make the file executable on macOS/Linux: chmod +x server.js. Publish to npm:
npm publish
Now anyone can run it without cloning:
npx -y file-helper-mcp
And their Claude Desktop config becomes:
{
"mcpServers": {
"file-helper": { "command": "npx", "args": ["-y", "file-helper-mcp"] }
}
}
Which is how almost every published MCP server is installed today.
Common gotchas
Things that trip up first-time MCP builders:
| Gotcha | Symptom | Fix |
|---|---|---|
Forgot "type": "module" in package.json |
SyntaxError: Cannot use import |
Add it |
Used a relative path in claude_desktop_config.json |
Server silently fails to start | Use the absolute path |
| Did not restart Claude Desktop fully | Tools do not appear | Quit and reopen, not just close-window |
Used console.log() in the server |
Inspector or Claude crashes | Use console.error() — stdout is reserved for MCP messages |
| Tool description is vague | LLM never calls the tool | Be specific: "List files in a directory" beats "file utility" |
The console.log one bites everyone exactly once. Remember: in stdio mode, stdout is the protocol channel. Any text you print there gets interpreted as malformed JSON-RPC and crashes the connection. Use console.error() for all your debugging output — that goes to stderr and Claude Desktop captures it in a log file.
What you just built
In under 100 lines of JavaScript, you have:
- A standards-compliant MCP server
- Two real tools with validated input schemas
- Local testing via the Inspector
- Integration with Claude Desktop
- A path to publish and distribute
The same pattern scales to dozens of tools across hundreds of servers. The MCP ecosystem you see today — GitHub server, Postgres server, Slack server — is all built on this exact foundation.
Next steps
Ideas to extend your file-helper:
- Add a
write_filetool (be careful with safety — confirm intent, restrict paths) - Add a
search_filestool that recurses and matches a glob pattern - Convert it to TypeScript for stricter type safety
- Add resources so users can attach specific files to a Claude conversation
- Add prompts for common workflows (e.g., "summarize this file")
Each of those is a small extension of what you already have — same server.tool() and server.resource() patterns, different business logic.
Conclusion
Building your first MCP server is genuinely shorter than reading the spec. The SDK does almost everything for you — schema generation, validation, transport, lifecycle. Your job is to write the tool handler and a good description.
If you got this working, you have just unlocked an entire category of AI integrations. The same code structure works for connecting Claude to your database, your monorepo, your internal API, or anything else with a programmable interface.
Go build something useful.
Try it yourself
With file-helper-mcp connected to Claude Desktop, here is what asking about your filesystem looks like:
~/Documents/Projects folder?list_filesContents of /Users/you/Documents/Projects:📁 client-website
📁 internal-dashboard
📁 mcp-experiments
📄 README.md
📄 backlog.txt
read_fileThe backlog has 14 items, mostly small UI polish tasks and three bigger feature requests (a new export format, role-based permissions, and a dark-mode toggle). The top three by priority are the export format, fixing a login redirect bug, and adding pagination to the user list.Two distinct tool calls, two clearly visible request/response pairs, and the LLM stitched it all into a coherent answer — that is the developer experience an MCP server unlocks.