# How to Build Your First MCP App (From Zero to Working Tool in 30 Minutes)

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 it
- `list_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 --version` should say v18 or higher)
- **Claude Desktop** installed ([download here](https://claude.ai/download))
- 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

```bash
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:

```json
"scripts": {
  "build": "tsc",
  "dev": "tsx src/index.ts",
  "start": "node dist/index.js"
}
```

---

## Step 2 — Write the Server

Create `src/index.ts`:

```typescript
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

```bash
npm install zod
npm run build
```

You should see a `dist/` folder with compiled JS. Test it manually:

```bash
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:

```json
{
  "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:

```bash
# 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:

```bash
# 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:

1. **Add a `write_file` tool** — let Claude create or edit files (add a confirmation prompt pattern for safety)
2. **Search tool** — implement grep-style search across a directory tree
3. **Watch mode** — expose a tool that tails a log file and returns the last N lines
4. **Database access** — swap the filesystem for a SQLite query tool using `better-sqlite3`
5. **HTTP requests** — add a `fetch_url` tool 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](https://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](https://getmcpapps.com)*
