Skip to main content

Command Palette

Search for a command to run...

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

A no-nonsense walkthrough to building a real MCP server with TypeScript — and connecting it to Claude Desktop

Updated
5 min read
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)
  • 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

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:

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

Step 2 — Write the Server

Create src/index.ts:

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

npm install zod
npm run build

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

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:

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

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

# 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

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

More from this blog

T

The Plug

22 posts