I Built My First MCP Server (And You Can Too)
Last week I spent 3 hours trying to figure out why Claude couldn't access my local files. Turns out, I needed an MCP server. So I built one.
If you're like me and thought "MCP sounds complicated," I've got good news: it's actually pretty straightforward once you get past the jargon. This isn't a "hello world" tutorial—I'm going to show you how to build something actually useful.
Why You Actually Need This
Here's the thing: Claude Desktop is great, but it can't access your filesystem. Want Claude to help refactor code? You're copy-pasting files. Want it to analyze logs? More copy-pasting.
MCP servers fix this. They're basically bridges that give AI assistants controlled access to your local tools and data.
I use mine for:
- Searching through codebases (grep on steroids)
- Reading and analyzing log files
- Checking git history without leaving Claude
- Running local scripts and getting results
What You Need
Before we start, make sure you have:
- Node.js 18+ installed (
node --versionto check) - Claude Desktop (the app, not the web version)
- A code editor (VS Code, whatever you use)
- Basic TypeScript knowledge (or just copy-paste, I won't judge)
That's it. No Docker, no cloud accounts, no BS.
Step 1: Project Setup (The Boring Part)
Create a new directory and initialize it:
mkdir file-search-mcp
cd file-search-mcp
npm init -y Install the MCP SDK:
npm install @modelcontextprotocol/sdk
npm install -D typescript @types/node tsx Quick note: The MCP SDK is still evolving. If you get version errors, check the official repo for the latest install command.
Create a tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true
}
} Step 2: The Actual Server Code
Create src/index.ts. Here's the skeleton:
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 * as fs from "fs/promises";
import * as path from "path";
const server = new Server(
{
name: "file-search-server",
version: "1.0.0",
},
{
capabilities: {
tools: {},
},
}
);
// Tool handlers go here
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("File Search MCP Server running on stdio");
}
main().catch((error) => {
console.error("Server error:", error);
process.exit(1);
});
Nothing fancy yet. The console.error is intentional—MCP uses stdout for protocol messages, so logs go to stderr.
Step 3: Define the Search Tool
Now for the interesting part. Add this before main():
// List available tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "search_files",
description: "Search for files by name or content in a directory. Returns file paths and matching lines.",
inputSchema: {
type: "object",
properties: {
directory: {
type: "string",
description: "Directory to search in (absolute path)",
},
pattern: {
type: "string",
description: "Search pattern (filename or content to search for)",
},
searchContent: {
type: "boolean",
description: "If true, search file contents. If false, search filenames only.",
default: false,
},
},
required: ["directory", "pattern"],
},
},
],
};
}); This tells Claude: "Hey, I can search files for you. Give me a directory and a pattern."
The inputSchema is JSON Schema. Claude uses this to know what parameters to send. Get this wrong and you'll spend an hour debugging why Claude keeps sending malformed requests (ask me how I know).
Step 4: Implement the Search Logic
Here's where it gets real. Add this handler:
// Handle tool calls
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === "search_files") {
const { directory, pattern, searchContent = false } = request.params.arguments as {
directory: string;
pattern: string;
searchContent?: boolean;
};
try {
const results: string[] = [];
async function searchDir(dir: string) {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
// Skip node_modules and .git (trust me on this)
if (entry.name === 'node_modules' || entry.name === '.git') {
continue;
}
if (entry.isDirectory()) {
await searchDir(fullPath);
} else if (entry.isFile()) {
// Search filename
if (!searchContent && entry.name.includes(pattern)) {
results.push(fullPath);
}
// Search content
if (searchContent) {
const content = await fs.readFile(fullPath, 'utf-8');
const lines = content.split('\n');
lines.forEach((line, idx) => {
if (line.includes(pattern)) {
results.push(`${fullPath}:${idx + 1}: ${line.trim()}`);
}
});
}
}
}
}
await searchDir(directory);
return {
content: [
{
type: "text",
text: results.length > 0
? `Found ${results.length} matches:\n${results.slice(0, 50).join('\n')}`
: `No matches found for "${pattern}" in ${directory}`,
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
},
],
isError: true,
};
}
}
throw new Error(`Unknown tool: ${request.params.name}`);
}); Step 5: Test It Locally First
Before connecting to Claude, make sure it actually runs:
npx tsx src/index.ts
You should see: File Search MCP Server running on stdio
If you get errors about missing modules, double-check your imports. The MCP SDK uses ES modules, so make sure your tsconfig.json has "module": "Node16".
Hit Ctrl+C to stop it. If it's running, you're good to go.
Step 6: Connect to Claude Desktop
This is where most people get stuck. Here's the config file location:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json - Linux:
~/.config/Claude/claude_desktop_config.json
If the file doesn't exist, create it. Add this:
{
"mcpServers": {
"file-search": {
"command": "npx",
"args": [
"tsx",
"/absolute/path/to/your/file-search-mcp/src/index.ts"
]
}
}
} Critical: Use the FULL absolute path. ~/ won't work. ./ won't work. On Mac, that's something like /Users/yourname/projects/file-search-mcp/src/index.ts.
Save the file and restart Claude Desktop completely (quit and reopen, not just close the window).
Step 7: Actually Use It
Open Claude Desktop. If everything worked, you won't see any errors. Try asking:
Claude should use your search_files tool and show you the results. If it works, congrats! If not, check the troubleshooting section below.
Some other things to try:
- "Find all files named 'config' in my home directory"
- "Search for TODO comments in my project"
- "Show me all files that import React"
What I Learned Building This
A few things that weren't obvious from the docs:
- Logging is weird: Use
console.error()for debug logs, notconsole.log(). Stdout is reserved for MCP protocol messages. - Errors are silent: If your tool fails, Claude just says "tool failed" with no details. Add try-catch blocks everywhere and return error messages in the response.
- Schema matters: Get your JSON Schema wrong and Claude will send garbage data. Test with simple schemas first.
- Restart required: Every time you change the config, you MUST restart Claude Desktop. Just closing the window doesn't count.
Making It Better
This is a working prototype, but here's what I'd add for production use:
- File size limits: Don't try to read 2GB log files into memory
- Binary file detection: Skip images, videos, etc.
- Gitignore support: Respect
.gitignorepatterns - Better error messages: Tell users WHY something failed
- Result limits: Cap results at 100 matches or something reasonable
But honestly? Ship this version first. See if you actually use it. Then optimize.
Troubleshooting (Because Something Will Break)
Claude doesn't show my tool
99% of the time this is because:
- You didn't use an absolute path in the config
- You didn't restart Claude Desktop (quit completely, not just close)
- There's a syntax error in your
claude_desktop_config.json
Check Claude's logs: ~/Library/Logs/Claude/mcp*.log on Mac. Look for errors.
Tool calls fail silently
Add more error logging. Wrap everything in try-catch. Return error details in the response:
catch (error) {
return {
content: [{
type: "text",
text: `Error: ${error.message}\n${error.stack}`
}],
isError: true
};
} "Module not found" errors
Your tsconfig.json is probably wrong. Make sure module is set to "Node16" or "NodeNext", not "CommonJS".
Server starts but Claude can't connect
Make sure you're using StdioServerTransport, not HTTP transport. Claude Desktop only supports stdio.
What's Actually Useful
Now that you know how to build MCP servers, here are some I actually use daily:
- Git history: Let Claude search commits, blame, and diffs
- Database queries: Run read-only SQL queries (be careful with this one)
- API testing: Make HTTP requests and parse responses
- Log analysis: Search and parse application logs
- File operations: Read, write, and modify files (with safeguards)
The file search one we built is actually pretty useful. I use it to find where specific functions are defined, or to grep for error messages across a codebase.
Final Thoughts
MCP is still early. The docs are sparse, the error messages are cryptic, and you'll spend time debugging weird issues. But it's also incredibly powerful once you get it working.
Start simple. Build something you'll actually use. Don't over-engineer it. And when it breaks (it will), check the logs and add more error handling.
If you build something cool, let me know. I'm always curious what people are doing with this stuff.