Building a Custom MCP Server

Episode 3 20 min

Recap: What We Covered Last Time

In Episode 2, we built our first MCP Server — a clock tool and a BMI calculator. We learned how to install the SDK, create a server, and connect it to Claude Desktop. Now it’s time to take a bigger step and build a real, professional server.

From Samples to Reality

The tools from the previous episode were great for getting started, but in the real world, your needs are more complex. You want a server with multiple tools, each accepting different inputs, handling errors gracefully, and maintaining clean code structure. This episode covers all of that.

We’re going to build an MCP Server that accesses a filesystem — it can list files, read their contents, and even create new files. A real project you can extend later.

Prerequisites
This episode builds on Episode 2. You should be comfortable with building a basic MCP Server. If you haven’t read the previous episode, I recommend starting there.

Structure of a Professional MCP Server

Before writing code, let’s talk about structure. A well-built MCP Server consists of three main parts:

1. Server Definition: Name, version, and general server information. This tells the Client what it’s connecting to.

2. Tool Definitions: Each tool has a name, description, input schema, and execution function. This is the most important part — it’s where you declare what your server can do.

3. Transport and Connection: How the server communicates with the Client. We’re using Stdio for now, but MCP also supports HTTP/SSE.

Analogy
Think of it like building an online store. The server definition is your store name. Tools are your products — each has a description, specifications, and price. Transport is the shipping service that moves orders back and forth.

Building the Filesystem Server

Let’s get started. Create a new project folder:

mkdir mcp-filesystem-server
cd mcp-filesystem-server
npm init -y
npm install @modelcontextprotocol/sdk

Don’t forget to add "type": "module" to your package.json.

Now create index.js. Starting from the top:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import fs from "fs/promises";
import path from "path";

const ALLOWED_DIR = process.env.MCP_ALLOWED_DIR || process.cwd();

const server = new McpServer({
  name: "filesystem-server",
  version: "1.0.0"
});

The key thing here is ALLOWED_DIR. We’re specifying that the server can only access a particular directory. This is a critical security principle — an MCP Server should never have access to your entire filesystem.

Tool One: List Files

Our first tool lists files in a directory:

server.tool(
  "list_files",
  "List files and directories in a given path",
  {
    dirPath: {
      type: "string",
      description: "Relative path to the directory (default: root)"
    }
  },
  async ({ dirPath = "" }) => {
    const fullPath = path.resolve(ALLOWED_DIR, dirPath);

    if (!fullPath.startsWith(ALLOWED_DIR)) {
      return {
        content: [{ type: "text", text: "Error: Access denied. Path is outside allowed directory." }],
        isError: true
      };
    }

    const entries = await fs.readdir(fullPath, { withFileTypes: true });
    const result = entries.map(e =>
      `${e.isDirectory() ? "DIR" : "FILE"} ${e.name}`
    ).join("\n");

    return {
      content: [{ type: "text", text: result || "Empty directory" }]
    };
  }
);

Several important things in this code:

Security check: The line if (!fullPath.startsWith(ALLOWED_DIR)) ensures nobody can escape the allowed directory using ../ traversal. This is critical — without this check, the AI could access any file on your system.

isError: When there’s an error, we return isError: true. This tells the AI that the operation failed and it should inform the user.

withFileTypes: This option tells readdir to also return the type of each entry (file or directory).

Tool Two: Read File

server.tool(
  "read_file",
  "Read the contents of a file",
  {
    filePath: {
      type: "string",
      description: "Relative path to the file"
    }
  },
  async ({ filePath }) => {
    const fullPath = path.resolve(ALLOWED_DIR, filePath);

    if (!fullPath.startsWith(ALLOWED_DIR)) {
      return {
        content: [{ type: "text", text: "Error: Access denied." }],
        isError: true
      };
    }

    try {
      const content = await fs.readFile(fullPath, "utf-8");
      return {
        content: [{ type: "text", text: content }]
      };
    } catch (err) {
      return {
        content: [{ type: "text", text: `Error reading file: ${err.message}` }],
        isError: true
      };
    }
  }
);

Here we added try/catch. Why? Because the file might not exist or we might not have permission to read it. A well-built MCP Server should handle errors gracefully and return clear messages — not crash.

Important
Never send raw system errors directly to the AI. Sensitive information like full file paths, OS details, or stack traces could be exposed. Filter error messages and only return what’s necessary.

Tool Three: Write File

server.tool(
  "write_file",
  "Create or overwrite a file with the given content",
  {
    filePath: {
      type: "string",
      description: "Relative path for the file"
    },
    content: {
      type: "string",
      description: "Content to write to the file"
    }
  },
  async ({ filePath, content }) => {
    const fullPath = path.resolve(ALLOWED_DIR, filePath);

    if (!fullPath.startsWith(ALLOWED_DIR)) {
      return {
        content: [{ type: "text", text: "Error: Access denied." }],
        isError: true
      };
    }

    await fs.mkdir(path.dirname(fullPath), { recursive: true });
    await fs.writeFile(fullPath, content, "utf-8");

    return {
      content: [{ type: "text", text: `File written successfully: ${filePath}` }]
    };
  }
);

The new thing here is fs.mkdir with recursive: true. If intermediate directories don’t exist, they’re created automatically. For example, if you ask to write a file at reports/2026/may/data.txt, all the necessary folders are created.

Defining Precise Input Schemas

One of the most important parts of building a good MCP Server is defining precise input schemas. The schema tells the AI what inputs each tool expects, what types they are, and what constraints they have.

Why does this matter? Because the AI uses the descriptions and field types to decide how to invoke the tool. If descriptions are weak, the AI might call the tool incorrectly.

Let’s look at a more advanced example — a file search tool:

server.tool(
  "search_files",
  "Search for files matching a pattern in their name or content",
  {
    query: {
      type: "string",
      description: "Search term to look for in file names and content"
    },
    searchIn: {
      type: "string",
      description: "Where to search: 'name' for file names only, 'content' for file content only, 'both' for both",
      enum: ["name", "content", "both"]
    },
    maxResults: {
      type: "number",
      description: "Maximum number of results to return (default: 10)"
    }
  },
  async ({ query, searchIn = "both", maxResults = 10 }) => {
    // ... implementation
  }
);

Notice how each field has a clear description. The searchIn field uses an enum so the AI can only choose from valid values. maxResults has a default value — if the user doesn’t specify, it returns 10 results.

Note
Write tool and field descriptions in English. Language models work better with English text, and the probability of correct invocation is higher. Even if the user speaks another language, the AI understands English descriptions more reliably.

Professional Error Handling

A professional server takes errors seriously. Let’s build some helper functions:

function safeResult(text) {
  return { content: [{ type: "text", text }] };
}

function errorResult(message) {
  return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
}

function validatePath(filePath) {
  const fullPath = path.resolve(ALLOWED_DIR, filePath);
  if (!fullPath.startsWith(ALLOWED_DIR)) {
    throw new Error("Access denied: path outside allowed directory");
  }
  return fullPath;
}

Now you can write tools more cleanly:

server.tool("read_file", "Read file contents", {
  filePath: { type: "string", description: "Relative file path" }
}, async ({ filePath }) => {
  try {
    const fullPath = validatePath(filePath);
    const content = await fs.readFile(fullPath, "utf-8");
    return safeResult(content);
  } catch (err) {
    return errorResult(err.message);
  }
});

Much cleaner, right? Each tool only contains its own logic, and everything else is centralized.

Analogy
Think of each tool as a store employee. The validatePath function is the security guard at the door — every request passes through it first. safeResult and errorResult are standardized forms — all employees use the same format.

Connecting and Testing

At the bottom of your file, add the transport:

const transport = new StdioServerTransport();
await server.connect(transport);

And update your config file:

{
  "mcpServers": {
    "filesystem-server": {
      "command": "node",
      "args": ["/path/to/mcp-filesystem-server/index.js"],
      "env": {
        "MCP_ALLOWED_DIR": "/path/to/your/allowed/folder"
      }
    }
  }
}

Notice we added env to set the MCP_ALLOWED_DIR environment variable. This way, without changing the code, you can specify which folder the server has access to.

Restart Claude Desktop and test it:

  • “List the files in the directory”
  • “Read the contents of README.md”
  • “Create a file called notes.txt and write ‘hello’ in it”

What We Learned

This episode was packed with practical takeaways:

  • Server structure: Server definition + tools + transport
  • Security: Restricting access to a specific directory
  • Multiple tools: A server can have any number of tools
  • Precise schemas: Good descriptions + enum + default values
  • Error handling: try/catch + clear messages + isError
  • Environment variables: Configuration without code changes
Next Episode
In Episode 4: Resources and Prompts in MCP, we’ll explore two more MCP Server capabilities. You’ll learn how to expose data directly to the AI (Resources) and create pre-defined interaction templates (Prompts). These are exactly what transform your server from “good” to “great.”