Build Your First MCP Server: A Complete Step-by-Step Tutorial

Ready to build your first Model Context Protocol (MCP) server? This comprehensive tutorial will guide you through creating a production-ready MCP server from scratch. By the end, you'll have a fully functional server that AI assistants can connect to and interact with.

What You'll Build

We'll create a file management MCP server that provides:

  • Secure file system access
  • File reading and writing capabilities
  • Directory listing functionality
  • Proper error handling and security
  • Production-ready deployment features

Prerequisites

Before we start, make sure you have the following installed:

New to TypeScript? Don't worry! This tutorial includes all the TypeScript code you need, and we'll explain the key concepts as we go.

Step 1: Project Setup

Let's start by creating a new project directory and initializing our MCP server project.

Create the Project

Open your terminal and run the following commands:

# Create project directory
mkdir file-manager-mcp-server
cd file-manager-mcp-server

# Initialize npm project
npm init -y

# Create source directory
mkdir src
mkdir src/tools
mkdir src/resources

Install Dependencies

Install the required MCP SDK and development dependencies:

# Install MCP SDK
npm install @modelcontextprotocol/sdk

# Install development dependencies
npm install -D typescript @types/node tsx nodemon

# Install additional dependencies for file operations
npm install fs-extra path-to-regexp

Configure TypeScript

Create a tsconfig.json file in your project root:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Node",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Update Package.json Scripts

Update your package.json to include useful scripts:

{
  "name": "file-manager-mcp-server",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "build": "tsc",
    "dev": "tsx watch src/index.ts",
    "start": "tsx src/index.ts",
    "inspect": "tsx --inspect src/index.ts"
  },
  "dependencies": {
    "@modelcontextprotocol/sdk": "latest",
    "fs-extra": "^11.2.0",
    "path-to-regexp": "^6.2.1"
  },
  "devDependencies": {
    "@types/node": "^20.10.0",
    "@types/fs-extra": "^11.0.4",
    "tsx": "^4.6.0",
    "typescript": "^5.3.0"
  }
}

Step 2: Creating the Basic Server

Now let's create the core MCP server structure. Create src/index.ts:

#!/usr/bin/env node

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 fs from "fs-extra";
import path from "path";

// Server configuration
const SERVER_NAME = "file-manager-server";
const SERVER_VERSION = "1.0.0";

class FileManagerServer {
  private server: Server;
  private allowedDirectory: string;

  constructor(allowedDirectory: string = process.cwd()) {
    this.allowedDirectory = path.resolve(allowedDirectory);
    
    // Initialize MCP server
    this.server = new Server(
      {
        name: SERVER_NAME,
        version: SERVER_VERSION,
      },
      {
        capabilities: {
          tools: {},
          resources: {},
        },
      }
    );

    this.setupHandlers();
  }

  private setupHandlers(): void {
    // Handle tool listing
    this.server.setRequestHandler(ListToolsRequestSchema, async () => {
      return {
        tools: [
          {
            name: "read_file",
            description: "Read the contents of a file",
            inputSchema: {
              type: "object",
              properties: {
                path: {
                  type: "string",
                  description: "Path to the file to read"
                }
              },
              required: ["path"]
            }
          },
          {
            name: "write_file",
            description: "Write content to a file",
            inputSchema: {
              type: "object",
              properties: {
                path: {
                  type: "string",
                  description: "Path to the file to write"
                },
                content: {
                  type: "string",
                  description: "Content to write to the file"
                }
              },
              required: ["path", "content"]
            }
          },
          {
            name: "list_directory",
            description: "List contents of a directory",
            inputSchema: {
              type: "object",
              properties: {
                path: {
                  type: "string",
                  description: "Path to the directory to list"
                }
              },
              required: ["path"]
            }
          }
        ]
      };
    });

    // Handle tool execution
    this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
      const { name, arguments: args } = request.params;

      try {
        switch (name) {
          case "read_file":
            return await this.handleReadFile(args.path);
          
          case "write_file":
            return await this.handleWriteFile(args.path, args.content);
          
          case "list_directory":
            return await this.handleListDirectory(args.path);
          
          default:
            throw new Error(`Unknown tool: ${name}`);
        }
      } catch (error) {
        return {
          content: [
            {
              type: "text",
              text: `Error: ${error instanceof Error ? error.message : String(error)}`
            }
          ],
          isError: true
        };
      }
    });
  }

  private validatePath(inputPath: string): string {
    // Resolve the path relative to allowed directory
    const resolvedPath = path.resolve(this.allowedDirectory, inputPath);
    
    // Ensure the path is within the allowed directory
    if (!resolvedPath.startsWith(this.allowedDirectory)) {
      throw new Error("Access denied: Path outside allowed directory");
    }
    
    return resolvedPath;
  }

  private async handleReadFile(filePath: string) {
    const safePath = this.validatePath(filePath);
    
    // Check if file exists
    if (!await fs.pathExists(safePath)) {
      throw new Error(`File not found: ${filePath}`);
    }
    
    // Check if it's actually a file
    const stats = await fs.stat(safePath);
    if (!stats.isFile()) {
      throw new Error(`Path is not a file: ${filePath}`);
    }
    
    const content = await fs.readFile(safePath, 'utf-8');
    
    return {
      content: [
        {
          type: "text",
          text: content
        }
      ]
    };
  }

  private async handleWriteFile(filePath: string, content: string) {
    const safePath = this.validatePath(filePath);
    
    // Ensure directory exists
    await fs.ensureDir(path.dirname(safePath));
    
    // Write the file
    await fs.writeFile(safePath, content, 'utf-8');
    
    return {
      content: [
        {
          type: "text",
          text: `Successfully wrote ${content.length} characters to ${filePath}`
        }
      ]
    };
  }

  private async handleListDirectory(dirPath: string) {
    const safePath = this.validatePath(dirPath);
    
    // Check if directory exists
    if (!await fs.pathExists(safePath)) {
      throw new Error(`Directory not found: ${dirPath}`);
    }
    
    // Check if it's actually a directory
    const stats = await fs.stat(safePath);
    if (!stats.isDirectory()) {
      throw new Error(`Path is not a directory: ${dirPath}`);
    }
    
    const items = await fs.readdir(safePath, { withFileTypes: true });
    
    const listing = items.map(item => {
      const type = item.isDirectory() ? 'directory' : 'file';
      const size = item.isFile() ? fs.statSync(path.join(safePath, item.name)).size : null;
      
      return {
        name: item.name,
        type,
        size
      };
    });
    
    return {
      content: [
        {
          type: "text",
          text: JSON.stringify(listing, null, 2)
        }
      ]
    };
  }

  async run(): Promise {
    const transport = new StdioServerTransport();
    await this.server.connect(transport);
    
    console.error(`${SERVER_NAME} v${SERVER_VERSION} running on stdio`);
    console.error(`Allowed directory: ${this.allowedDirectory}`);
  }
}

// Start the server
async function main() {
  // Get allowed directory from environment variable or use current directory
  const allowedDir = process.env.ALLOWED_DIRECTORY || process.cwd();
  
  const server = new FileManagerServer(allowedDir);
  await server.run();
}

// Handle graceful shutdown
process.on('SIGINT', () => {
  console.error('\nShutting down gracefully...');
  process.exit(0);
});

process.on('SIGTERM', () => {
  console.error('\nShutting down gracefully...');
  process.exit(0);
});

main().catch((error) => {
  console.error('Failed to start server:', error);
  process.exit(1);
});

Step 3: Testing Your Server

Now let's test our server to make sure it works correctly.

Create Test Files

First, create some test files in your project directory:

# Create test directory and files
mkdir test-files
echo "Hello, MCP World!" > test-files/hello.txt
echo "This is a test file for our MCP server." > test-files/test.md

Test with MCP Inspector

The MCP SDK includes a handy inspector tool for testing servers. Install it globally:

npm install -g @modelcontextprotocol/inspector

Now test your server:

# Start the server with inspector
npx @modelcontextprotocol/inspector tsx src/index.ts

The inspector will open in your browser, showing your server's tools and allowing you to test them interactively.

Manual Testing

You can also test the server manually. Start it in development mode:

npm run dev

The server is now running and ready to receive MCP requests via stdio.

Step 4: Production Deployment

Let's prepare our server for production deployment with proper packaging and deployment scripts.

Create Deployment Scripts

Create scripts/build.sh:

#!/bin/bash
set -e

echo "Building MCP File Manager Server..."

# Clean previous build
rm -rf dist

# Build TypeScript
npx tsc

# Copy package.json to dist
cp package.json dist/

# Install production dependencies in dist
cd dist
npm install --production
cd ..

echo "Build complete! Files are in ./dist"

Create scripts/start.sh:

#!/bin/bash

# Set production environment
export NODE_ENV=production

# Set security defaults
export ALLOWED_DIRECTORY=${ALLOWED_DIRECTORY:-"/opt/mcp-data"}

# Start the server
node dist/index.js

Docker Deployment

Create a Dockerfile for containerized deployment:

FROM node:18-alpine

# Create app directory
WORKDIR /usr/src/app

# Create non-root user
RUN addgroup -g 1001 -S mcp && \
    adduser -S mcp -u 1001

# Copy package files
COPY package*.json ./

# Install dependencies
RUN npm ci --only=production

# Copy built application
COPY dist/ ./

# Create data directory
RUN mkdir -p /opt/mcp-data && \
    chown mcp:mcp /opt/mcp-data

# Switch to non-root user
USER mcp

# Set environment defaults
ENV NODE_ENV=production
ENV ALLOWED_DIRECTORY=/opt/mcp-data

CMD ["node", "index.js"]

Conclusion

Congratulations! You've built a complete, production-ready MCP server with the following features:

Next Steps

  • Deploy your server using Docker or your preferred hosting platform
  • Configure Claude Desktop to use your server
  • Add more tools for your specific use case
  • Implement resource support for read-only data access
  • Add HTTP transport support for web-based deployments

Connecting to Claude Desktop

To use your server with Claude Desktop, add it to your Claude configuration file:

{
  "mcpServers": {
    "file-manager": {
      "command": "node",
      "args": ["/path/to/your/server/dist/index.js"],
      "env": {
        "ALLOWED_DIRECTORY": "/path/to/safe/directory"
      }
    }
  }
}

Resources for Further Learning

You now have the foundation to build sophisticated MCP servers that can connect AI assistants to any data source or service. The patterns and techniques you've learned here can be applied to create servers for databases, APIs, cloud services, and much more.

Need help? Join the MCP community discussions and share your server implementations. The community is always eager to help newcomers and learn from creative implementations!