I Built My First MCP Server (And You Can Too)

January 22, 2026 12 min read

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.

What we're building: A file search MCP server that lets Claude search through your project files. Way more useful than a weather API demo.

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:

What You Need

Before we start, make sure you have:

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}`);
});
Real talk: This code isn't perfect. It doesn't handle binary files, huge files will crash it, and there's no rate limiting. But it works, and you can improve it later. Ship first, optimize second.

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:

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:

"Can you search for all TypeScript files in /Users/yourname/projects/my-app that contain 'useState'?"

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:

What I Learned Building This

A few things that weren't obvious from the docs:

Making It Better

This is a working prototype, but here's what I'd add for production use:

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:

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:

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.