Build Your First MCP Tool: A ReadFile Guide

Alex Johnson
-
Build Your First MCP Tool: A ReadFile Guide

Hello, fellow developers! I'm Nicolas Dabène.

Do you remember that feeling when a complex theory suddenly clicks, and your code runs flawlessly? That's exactly what we're aiming for today. Now that we've set up our TypeScript environment from our previous discussions, it's time to build something truly practical: your first Model Context Protocol (MCP) tool. We're going to empower AI to interact directly with your machine's file system, starting with a simple yet powerful readFile function. This isn't just about theory; it's about real, runnable code.

Imagine telling an AI, "Read the file project_report.md," and it can retrieve the file's contents. This interaction is made possible by the MCP server we're building. By mastering this tool, you'll be able to create a whole suite of custom functionalities for AI.

Unveiling MCP Tools: A Quick Overview

Before diving into the code, let's quickly recap the basic concepts of MCP tools. At its core, an MCP tool is essentially a function that you expose to the AI. This exposure requires three key pieces of metadata to help the AI understand and use your tool:

  • Tool Name: A unique identifier the AI uses to call your tool (e.g., "readFile").
  • Clear Description: Explains the tool's purpose, guiding the AI on when to use it effectively.
  • Parameters: Defines the input data the tool expects to receive when performing its operation.

You can think of it as providing a comprehensive instruction manual for your function, allowing the AI to read and understand it. Simple, right?

The Building Blocks of an MCP Tool

Every MCP tool we create will follow a consistent structure. This framework ensures maintainability and clarity, making your toolset easier to expand. Here's the typical layout we'll follow:

// 1. Interface for input parameters
interface ToolParams {
  // Data the AI sends us
}

// 2. Interface for the tool's response
interface ToolResponse {
  success: boolean;
  content?: string;
  error?: string;
}

// 3. The asynchronous function that contains the tool's core logic
async function myTool(params: ToolParams): Promise<ToolResponse> {
  // Your business logic goes here
}

// 4. The tool's formal definition, recognizable by the AI
export const myToolDefinition = {
  name: "myTool",
  description: "A brief explanation of what my tool achieves",
  parameters: {
    // Detailed description of expected input parameters
  }
};

This four-part schema will serve as our blueprint for building powerful and AI-friendly tools.

Laying the Foundation for Our Project

Let's build a clean and scalable architecture for our mcp-server project. Run the following commands to create the necessary directories:

mkdir -p src/tools
mkdir -p src/types

The src/tools folder will house our individual MCP tools, while src/types will store our shared TypeScript interface definitions, ensuring type safety and consistency across the project.

Defining Essential TypeScript Interfaces

The next step is to create the foundational TypeScript interfaces. Inside src/types/mcp.ts, add the following code:

// src/types/mcp.ts

// Generic type for tool parameters, allowing for flexible inputs
export interface ToolParams {
  [key: string]: any;
}

// Standardized structure for a tool's response
export interface ToolResponse {
  success: boolean;
  content?: string; // Optional: for textual output
  error?: string;   // Optional: for error messages
  metadata?: {      // Optional: for additional structured data
    [key: string]: any;
  };
}

// Interface for the formal definition of a tool, as presented to the AI
export interface ToolDefinition {
  name: string;
  description: string;
  parameters: {
    [paramName: string]: {
      type: string;        // e.g., "string", "number", "boolean"
      description: string; // Explains the parameter's role
      required: boolean;   // Indicates if the parameter is mandatory
    };
  };
}

// Specific type for the parameters required by our readFile tool
export interface ReadFileParams extends ToolParams {
  file_path: string;
}

These interfaces are invaluable. They provide strong typing, enable autocompletion, and catch potential errors during development, making TypeScript an indispensable assistant in this project. With well-defined interfaces, we ensure that our MCP tools are robust and reliable, and TypeScript helps us catch any type-related issues early on.

Building the readFile Tool

Now, for the main event! Let's implement the readFile tool. Create the file src/tools/readFile.ts and populate it with the following code:

// src/tools/readFile.ts
import fs from 'fs/promises';
import path from 'path';
import { ReadFileParams, ToolResponse, ToolDefinition, ToolParams } from '../types/mcp';

/**
 * Reads the content of a text file from the local file system.
 * Includes robust validation and security checks.
 * @param params - Parameters containing the file path and optional encoding.
 * @returns A promise resolving to a ToolResponse with the file content or an error.
 */
export async function readFile(params: ReadFileParams): Promise<ToolResponse> {
  try {
    // Step 1: Input Validation
    if (!params.file_path) {
      return {
        success: false,
        error: "The 'file_path' parameter is required."
      };
    }

    // Step 2: Security - Resolve Absolute Path
    // This critical step prevents directory traversal attacks (e.g., '../../etc/passwd').
    const absolutePath = path.resolve(params.file_path);

    // Step 3: Verify File Existence
    try {
      await fs.access(absolutePath);
    } catch {
      return {
        success: false,
        error: `File not found at path: '${params.file_path}'`
      };
    }

    // Step 4: Retrieve File Information
    const stats = await fs.stat(absolutePath);

    // Step 5: Confirm it's a file, not a directory
    if (!stats.isFile()) {
      return {
        success: false,
        error: "The specified path points to a directory, not a file."
      };
    }

    // Step 6: Enforce Size Limit (Security & Performance)
    // Prevents accidental loading of excessively large files into memory.
    const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB limit
    if (stats.size > MAX_FILE_SIZE) {
      return {
        success: false,
        error: `File size exceeds the maximum allowed (${MAX_FILE_SIZE / (1024 * 1024)} MB).`
      };
    }

    // Step 7: Read File Content with specified encoding (defaulting to UTF-8)
    const encoding: BufferEncoding = (params.encoding || 'utf-8') as BufferEncoding;
    const content = await fs.readFile(absolutePath, encoding);

    // Step 8: Return Success with Content and Useful Metadata
    return {
      success: true,
      content: content.toString(), // Ensure content is a string
      metadata: {
        path: absolutePath,
        size: stats.size,
        encoding: encoding,
        lastModified: stats.mtime.toISOString()
      }
    };

  } catch (error: any) {
    // Step 9: Handle Unexpected Errors Gracefully
    return {
      success: false,
      error: `An unexpected error occurred while reading the file: ${error.message}`
    };
  }
}

/**
 * The formal definition of the 'readFile' tool for the MCP protocol.
 * This is what the AI will "see" when it inspects available tools.
 */
export const readFileToolDefinition: ToolDefinition = {
  name: "readFile",
  description: "Reads the content of a text file from the local file system.",
  parameters: {
    file_path: {
      type: "string",
      description: "The absolute or relative path to the file to be read.",
      required: true
    },
    encoding: {
      type: "string",
      description: "The character encoding to use (e.g., 'utf-8', 'ascii', 'base64'). Defaults to 'utf-8'.",
      required: false
    }
  }
};

Take a moment to appreciate the thoughtfulness behind each step:

  • Validation: We always verify if critical parameters are provided.
  • Security: Path resolution prevents malicious attempts to access restricted areas. The path.resolve() function is crucial here as it transforms the user-provided file path into an absolute path, mitigating the risk of directory traversal attacks. Without proper path validation and sanitization, an attacker could potentially access sensitive files outside the intended directory.
  • Existence and Type Checks: We ensure the target exists and is a file, not a directory, preventing unexpected errors.
  • Size Limits: A practical way to prevent accidental loading of large files. By enforcing a file size limit, we prevent the application from crashing due to excessive memory usage, improving the stability and reliability of our MCP tool.
  • Robust Reading: Handles various encodings, offering great flexibility.
  • Enhanced Response: Provides not only the content but also valuable metadata.
  • Error Handling: Accurately captures and reports problems.

With these features, our readFile MCP tool is not only functional but also secure and robust.

Managing Tools Centrally with a Manager

To manage our growing toolset, let's create a central manager. Add the following to src/tools/index.ts:

// src/tools/index.ts
import { ToolDefinition, ToolResponse, ToolParams } from '../types/mcp';
import { readFile, readFileToolDefinition } from './readFile'; // Import our first tool

// A registry mapping tool names to their execution functions
export const tools = {
  readFile: readFile,
  // Add other tools here as you create them
};

// An array containing the formal definitions of all available tools
export const toolDefinitions: ToolDefinition[] = [
  readFileToolDefinition,
  // Add other tool definitions here
];

/**
 * A helper function to dynamically execute a tool by its name.
 * @param toolName - The name of the tool to execute.
 * @param params - The parameters to pass to the tool.
 * @returns A promise resolving to the tool's response.
 */
export async function executeTool(toolName: string, params: ToolParams): Promise<ToolResponse> {
  const tool = tools[toolName as keyof typeof tools]; // Type assertion for dynamic access

  if (!tool) {
    return {
      success: false,
      error: `Error: Tool '${toolName}' not found.`
    };
  }

  // Execute the tool function
  return await tool(params);
}

This index.ts file is our central hub. As you develop more MCP tools, simply register them here to make them discoverable and executable.

Integrating with an Express Server

Now, let's modify src/index.ts to expose our MCP tools via an HTTP endpoint using Express:

// src/index.ts
import express, { Request, Response } from 'express';
import { toolDefinitions, executeTool } from './tools'; // Import our tool manager

const app = express();
const PORT = 3000;

// Middleware to parse JSON request bodies
app.use(express.json());

// Basic health check route
app.get('/', (req: Request, res: Response) => {
  res.json({
    message: 'MCP Server is up and running!',
    version: '1.0.0'
  });
});

// Endpoint for AI to discover available tools (the "tool menu")
app.get('/tools', (req: Request, res: Response) => {
  res.json({
    success: true,
    tools: toolDefinitions
  });
});

// Endpoint for AI to execute a specific tool
app.post('/tools/:toolName', async (req: Request, res: Response) => {
  const { toolName } = req.params;
  const params = req.body; // Parameters sent by the AI

  try {
    const result = await executeTool(toolName, params);
    res.json(result); // Send the tool's response back
  } catch (error: any) {
    // Catch any unexpected server-side errors during tool execution
    res.status(500).json({
      success: false,
      error: `Server-side error during tool execution: ${error.message}`
    });
  }
});

// Start the server
app.listen(PORT, () => {
  console.log(`✅ MCP Server launched on http://localhost:${PORT}`);
  console.log(`📋 Discover tools: http://localhost:${PORT}/tools`);
});

Our Express server now exposes two crucial endpoints:

  • GET /tools: Provides a list of all available MCP tools and their definitions. This is how the AI learns about its capabilities.
  • POST /tools/:toolName: Allows the AI to call a specific tool, passing in the necessary parameters in the request body.

Time to Witness the Magic: Testing Our Tool!

Let's test the readFile tool. First, create a simple test file in your project's root directory:

echo "This is a test file for the MCP server. Hello, AI!" > test.txt

Now, start your MCP server:

npm run dev

You should see output similar to this:

✅ MCP Server launched on http://localhost:3000
📋 Discover tools: http://localhost:3000/tools

Test 1: Discover Available Tools

Open a new terminal window and query the server's /tools endpoint:

curl http://localhost:3000/tools

Expected Response:

{
  "success": true,
  "tools": [
    {
      "name": "readFile",
      "description": "Reads the content of a text file from the local file system.",
      "parameters": {
        "file_path": {
          "type": "string",
          "description": "The absolute or relative path to the file to be read.",
          "required": true
        },
        "encoding": {
          "type": "string",
          "description": "The character encoding to use (e.g., 'utf-8', 'ascii', 'base64'). Defaults to 'utf-8'.",
          "required": false
        }
      }
    }
  ]
}

Great! Your AI can now discover the readFile tool and understand its capabilities.

Test 2: Execute the readFile Tool

Let's use the readFile tool to retrieve the contents of test.txt:

curl -X POST http://localhost:3000/tools/readFile \
  -H "Content-Type: application/json" \
  -d '{"file_path": "test.txt"}'

Expected Response (path and date may vary):

{
  "success": true,
  "content": "This is a test file for the MCP server. Hello, AI!\n",
  "metadata": {
    "path": "/absolute/path/to/your/project/test.txt",
    "size": 47,
    "encoding": "utf-8",
    "lastModified": "2023-10-27T14:30:00.000Z"
  }
}

It worked! Your MCP server has successfully read the file.

Test 3: Observe Error Handling

Now, let's test with a non-existent file:

curl -X POST http://localhost:3000/tools/readFile \
  -H "Content-Type: application/json" \
  -d '{"file_path": "nonexistent_file.txt"}'

Response:

{
  "success": false,
  "error": "File not found at path: 'nonexistent_file.txt'"
}

Excellent! Our error handling is functioning correctly.

Expanding Your Toolset: The listFiles Tool

Now that you're capable of creating an MCP tool, let's quickly build another one: listFiles. This tool will allow the AI to inspect directory contents.

Create src/tools/listFiles.ts:

// src/tools/listFiles.ts
import fs from 'fs/promises';
import path from 'path';
import { ToolParams, ToolResponse, ToolDefinition } from '../types/mcp';

// Specific type for listFiles parameters
export interface ListFilesParams extends ToolParams {
  directory_path: string;
}

/**
 * Lists files and directories within a specified path.
 * @param params - Parameters containing the directory path.
 * @returns A promise resolving to a ToolResponse with directory contents or an error.
 */
export async function listFiles(params: ListFilesParams): Promise<ToolResponse> {
  try {
    if (!params.directory_path) {
      return {
        success: false,
        error: "The 'directory_path' parameter is required."
      };
    }

    const absolutePath = path.resolve(params.directory_path);

    // Verify it's a directory
    let stats;
    try {
      stats = await fs.stat(absolutePath);
    } catch (e: any) {
      if (e.code === 'ENOENT') {
        return { success: false, error: `Directory not found at path: '${params.directory_path}'` };
      }
      throw e; // Re-throw other errors
    }

    if (!stats.isDirectory()) {
      return {
        success: false,
        error: "The specified path is not a directory."
      };
    }

    // Read directory content
    const files = await fs.readdir(absolutePath);

    // Get details for each item
    const filesWithDetails = await Promise.all(
      files.map(async (file) => {
        const itemPath = path.join(absolutePath, file);
        const itemStats = await fs.stat(itemPath);

        return {
          name: file,
          type: itemStats.isDirectory() ? 'directory' : 'file',
          size: itemStats.size,
          lastModified: itemStats.mtime.toISOString()
        };
      })
    );

    return {
      success: true,
      content: JSON.stringify(filesWithDetails, null, 2), // Pretty-print JSON
      metadata: {
        path: absolutePath,
        count: filesWithDetails.length
      }
    };

  } catch (error: any) {
    return {
      success: false,
      error: `Error listing directory contents: ${error.message}`
    };
  }
}

/**
 * The formal definition of the 'listFiles' tool for the MCP protocol.
 */
export const listFilesToolDefinition: ToolDefinition = {
  name: "listFiles",
  description: "Lists files and subdirectories within a specified directory, providing their type, size, and last modification date.",
  parameters: {
    directory_path: {
      type: "string",
      description: "The absolute or relative path to the directory whose contents are to be listed.",
      required: true
    }
  }
};

Now, integrate this new tool into our src/tools/index.ts management system:

// src/tools/index.ts
import { ToolDefinition, ToolResponse, ToolParams } from '../types/mcp';
import { readFile, readFileToolDefinition } from './readFile';
import { listFiles, listFilesToolDefinition } from './listFiles'; // Import the new tool

export const tools = {
  readFile: readFile,
  listFiles: listFiles // Add listFiles to the registry
};

export const toolDefinitions: ToolDefinition[] = [
  readFileToolDefinition,
  listFilesToolDefinition // Add listFiles's definition
];

export async function executeTool(toolName: string, params: ToolParams): Promise<ToolResponse> {
  const tool = tools[toolName as keyof typeof tools];

  if (!tool) {
    return {
      success: false,
      error: `Error: Tool '${toolName}' not found.`
    };
  }

  return await tool(params);
}

Restart your server (npm run dev) and test the tool discovery feature again:

curl http://localhost:3000/tools

You'll now see both readFile and listFiles proudly listed!

Essential Best Practices and Security Considerations

As you expand the capabilities of your MCP tools, security becomes paramount. Here are some crucial best practices:

Always Validate Inputs

Never assume that input data is safe. Always validate data types, formats, lengths, and acceptable values. This is the first line of defense against malformed or malicious requests.

Implement Strict File Access Policies

By default, Node.js can access the entire file system. For AI-driven tools, you must restrict its access. Implement a whitelist mechanism of allowed directories:

const ALLOWED_DIRECTORIES = [
  path.resolve('/home/user/my-project-data'), // Example user data
  path.resolve(process.cwd()),                // Current working directory
];

function isPathAllowed(filePath: string): boolean {
  const absolute = path.resolve(filePath);
  // Ensure the resolved path starts with one of the allowed directories
  return ALLOWED_DIRECTORIES.some(dir => absolute.startsWith(dir + path.sep) || absolute === dir);
}
// Integrate this check into your readFile and listFiles functions

Enforce Size and Depth Limits

Prevent resource exhaustion by limiting:

  • File Sizes: As demonstrated in readFile, avoid loading excessively large files.
  • Result Counts: Applicable for directory listings or search results.
  • Recursion Depth: If using recursive tools, prevent infinite loops.

Log All Access and Operations

Detail which tools were executed, by whom (if authenticated), the parameters used, and the results. This is critical for auditing, debugging, and identifying suspicious activity.

console.log(`[${new Date().toISOString()}] Tool Executed: ${toolName}, Params: ${JSON.stringify(params)}`);

Conclusion

Congratulations, developer! You've just created and integrated your first fully functional MCP tools. You've moved beyond theory and have:

  • Built a robust MCP tool using TypeScript.
  • Managed parameters and crafted meaningful responses.
  • Implemented crucial input validation and error handling.
  • Exposed your tool through a clean REST API.
  • Tested your tool effectively using curl.
  • Established a pattern for creating and registering multiple tools.

This is a significant step towards building truly intelligent agents capable of interacting with your digital environment. What tools are you most excited to build next? Perhaps tools for searching file contents, analyzing structured data, or even automating deployment tasks? The possibilities are now endless for empowering AI. Learn more about AI tools and their security implications.

OWASP - AI Security and Privacy Guide

You may also like