MCP — the Model Context Protocol — is the standard that lets AI models talk to external tools and data sources through a consistent interface. Instead of writing custom integration code every time you want Claude to access a new service, you build or install an MCP server once, and any MCP-compatible model can use it.
Think of it as USB-C for AI tools. Every device doesn't need its own proprietary cable — one standard connector works everywhere.
By the end of this tutorial, you'll have a working MCP server that exposes tools to Claude. We'll build a simple file operations server — read files, list directories, search content — the kind of thing that's useful immediately.
How MCP works
MCP runs as a separate process that communicates with the AI client (Claude, Cursor, etc.) over a defined protocol. The flow:
- Client connects to your MCP server at startup
- Server registers tools — names, descriptions, input schemas
- Model decides to call a tool (based on the descriptions you provided)
- Client sends the tool call to your server
- Server executes the tool and returns results
- Model receives results and continues reasoning
The server is just a process that handles tool calls. You write the logic; MCP handles the communication layer.
Setup
You'll need Node.js 18+ (the MCP SDK is TypeScript/JavaScript-first, though Python SDKs exist).
mkdir my-mcp-server && cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk
Create src/server.ts:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import fs from "fs";
import path from "path";
Step 1: Create the server and register tools
const server = new McpServer({
name: "file-ops",
version: "1.0.0",
});
// Tool 1: Read a file
server.tool(
"read_file",
"Read the contents of a file at a given path. Returns the file content as text.",
{
file_path: z.string().describe("Absolute or relative path to the file"),
max_lines: z.number().optional().describe("Maximum lines to return. Omit for full file."),
},
async ({ file_path, max_lines }) => {
try {
const content = fs.readFileSync(file_path, "utf-8");
const lines = content.split("\n");
const output = max_lines ? lines.slice(0, max_lines).join("\n") : content;
return {
content: [{ type: "text", text: output }],
};
} catch (err) {
return {
content: [{ type: "text", text: `Error reading file: ${err.message}` }],
isError: true,
};
}
}
);
// Tool 2: List directory contents
server.tool(
"list_directory",
"List files and directories at a given path. Shows names, types, and sizes.",
{
dir_path: z.string().describe("Directory path to list"),
show_hidden: z.boolean().optional().describe("Include hidden files (starting with .). Default false."),
},
async ({ dir_path, show_hidden = false }) => {
try {
const entries = fs.readdirSync(dir_path, { withFileTypes: true });
const filtered = show_hidden ? entries : entries.filter(e => !e.name.startsWith("."));
const output = filtered.map(entry => {
const fullPath = path.join(dir_path, entry.name);
const stats = fs.statSync(fullPath);
const type = entry.isDirectory() ? "dir" : "file";
const size = entry.isFile() ? `${Math.round(stats.size / 1024)}KB` : "";
return `${type.padEnd(5)} ${entry.name} ${size}`;
}).join("\n");
return {
content: [{ type: "text", text: output || "Empty directory" }],
};
} catch (err) {
return {
content: [{ type: "text", text: `Error listing directory: ${err.message}` }],
isError: true,
};
}
}
);
// Tool 3: Search file contents
server.tool(
"search_files",
"Search for a text pattern across files in a directory. Returns matching lines with file names and line numbers.",
{
directory: z.string().describe("Directory to search in"),
pattern: z.string().describe("Text or regex pattern to search for"),
file_extension: z.string().optional().describe("Filter by extension, e.g. '.ts', '.md'"),
},
async ({ directory, pattern, file_extension }) => {
const results: string[] = [];
const regex = new RegExp(pattern, "gi");
function searchDir(dir: string) {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.name.startsWith(".")) continue;
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
searchDir(fullPath);
} else if (!file_extension || entry.name.endsWith(file_extension)) {
try {
const content = fs.readFileSync(fullPath, "utf-8");
const lines = content.split("\n");
lines.forEach((line, i) => {
if (regex.test(line)) {
results.push(`${fullPath}:${i + 1}: ${line.trim()}`);
}
});
} catch { /* skip unreadable files */ }
}
}
}
searchDir(directory);
const output = results.length > 0
? results.slice(0, 50).join("\n") // cap at 50 results
: "No matches found";
return {
content: [{ type: "text", text: output }],
};
}
);
Step 2: Start the server
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("File ops MCP server running");
}
main().catch(console.error);
The server communicates over stdio (standard input/output). MCP clients launch it as a subprocess and pipe messages back and forth. You don't need to manage ports or HTTP.
Step 3: Connect to Claude Desktop (or Claude Code)
Claude Desktop
Open your Claude Desktop config file:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json
Add your server:
{
"mcpServers": {
"file-ops": {
"command": "node",
"args": ["/absolute/path/to/my-mcp-server/dist/server.js"],
"env": {}
}
}
}
Build your server (npx tsc) and restart Claude Desktop. You should see "file-ops" in the tools panel. Claude can now call your tools automatically when a conversation calls for it.
Claude Code (CLI)
If you're using OpenClaw or Claude Code in your terminal:
claude mcp add file-ops node /absolute/path/to/dist/server.js
Step 4: Test it
Start a new Claude conversation and ask:
"List the files in my home directory, then read the README if there is one"
Claude will call list_directory, see the README, call read_file, and summarise it — all without you specifying which tools to use. That's the point: you describe the tools well and Claude figures out the orchestration.
Tool description quality is everything
Claude's decisions about when to call your tools come entirely from the descriptions. These are not just documentation — they're the prompt that controls tool selection.
Weak description:
"search_files": "Search files"
Strong description:
"search_files": "Search for a text pattern across files in a directory. Returns matching lines with file names and line numbers. Use this when you need to find where something is defined, referenced, or mentioned across multiple files."
The difference: the strong version tells Claude when to use it, not just what it does. Add "use this when..." to every tool description.
Adding resources (not just tools)
MCP supports two primary primitives: tools (functions Claude can call) and resources (data Claude can read). Resources are useful for structured data that doesn't change during a conversation — configuration, documentation, schemas.
server.resource(
"project-config",
"project://config",
async (uri) => ({
contents: [{
uri: uri.href,
text: fs.readFileSync("./project.json", "utf-8"),
mimeType: "application/json",
}],
})
);
Resources appear in Claude's context automatically when relevant, rather than being called on demand like tools.
Common patterns and mistakes
Return errors gracefully, not exceptions
// Don't throw — return an error result
return {
content: [{ type: "text", text: `Error: ${err.message}` }],
isError: true,
};
Claude handles error results better than it handles crashed tools. Return structured errors and Claude will adapt its approach.
Keep tool outputs focused
If a tool returns 10,000 lines of content, Claude processes all of it but may lose the important parts. Add max_lines parameters, truncate large outputs, and summarise where possible. A focused result is more useful than a complete one.
Log everything during development
console.error(`[file-ops] read_file called: ${file_path}`);
Use console.error (not console.log) — MCP communicates over stdout, so stdout is reserved for the protocol. Errors go to stderr and appear in your terminal without interfering with the server.
One server per domain
Don't build one giant MCP server with 30 tools. Build focused servers: one for file operations, one for database access, one for your company's internal API. Smaller servers are easier to test, easier to debug, and easier to reason about.
What to build next
Once you're comfortable with the basics:
- Database MCP server — expose SQL queries as tools. Claude can then answer questions about your data directly.
- API wrapper server — wrap any REST API. Expose endpoints as tools with clear descriptions and Claude can orchestrate API calls autonomously.
- Company knowledge server — expose internal docs, wikis, or Notion pages as resources. Claude can pull in relevant context automatically.
The full MCP protocol spec and a library of community-built servers are at modelcontextprotocol.io. Check the server registry before building something that already exists — there are now hundreds of pre-built servers for common services.
For the conceptual overview of why MCP matters, the MCP complete guide covers the broader picture. This tutorial is the build-it-yourself companion.



