MCP Server Setup: Complete Developer Guide for Model Context Protocol

Published March 31, 2026 • 12 min read

The $1.8B Model Context Protocol market is heating up. Here's how to build and deploy your first MCP server before your competition does.

MCP servers are the missing infrastructure layer between AI models and your data. While everyone debates which AI model is "best," the smart developers are building the context plumbing that makes any model 10x more useful.

If you've ever wondered why Claude suddenly knows about your codebase or why ChatGPT can access your database—that's MCP working behind the scenes.

This guide gets you from zero to a production MCP server in 30 minutes. No theory. Just working code.

What You're Building

By the end of this guide, you'll have:

  • A functioning MCP server that any AI model can connect to
  • Custom tools that extend AI capabilities with your data
  • Authentication and security properly configured
  • A deployment setup that scales

We're building a file system MCP server—the kind that lets AI models read, write, and organize files intelligently. It's practical and immediately useful.

Prerequisites and Setup

You need:

  • Node.js 18+ (the MCP SDK is JavaScript-first)
  • Basic understanding of JSON-RPC (don't panic—it's simple)
  • 15 minutes to get something working

Initialize Your MCP Server Project

mkdir mcp-file-server
cd mcp-file-server
npm init -y
npm install @modelcontextprotocol/sdk server-stdio
npm install --save-dev typescript @types/node

Create the basic project structure:

mkdir src tools
touch src/index.ts src/server.ts tools/file-operations.ts

Core MCP Server Implementation

The Main Server File

Your `src/server.ts` is the heart of the MCP server:

import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
  CallToolRequestSchema,
  ErrorCode,
  ListToolsRequestSchema,
  McpError,
} from '@modelcontextprotocol/sdk/types.js';
import { FileOperations } from '../tools/file-operations.js';

interface Tool {
  name: string;
  description: string;
  inputSchema: object;
}

class FileServerMCP {
  private server: Server;
  private fileOps: FileOperations;

  constructor() {
    this.server = new Server(
      {
        name: "file-server-mcp",
        version: "1.0.0",
      },
      {
        capabilities: {
          tools: {}
        }
      }
    );

    this.fileOps = new FileOperations();
    this.setupToolHandlers();
    this.setupErrorHandlers();
  }

  private setupToolHandlers(): void {
    this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
      tools: [
        {
          name: "read_file",
          description: "Read contents of a file",
          inputSchema: {
            type: "object",
            properties: {
              path: { type: "string", description: "File path to read" }
            },
            required: ["path"]
          }
        },
        {
          name: "write_file",
          description: "Write content to a file",
          inputSchema: {
            type: "object",
            properties: {
              path: { type: "string", description: "File path to write" },
              content: { type: "string", description: "Content to write" }
            },
            required: ["path", "content"]
          }
        },
        {
          name: "list_directory",
          description: "List files in a directory",
          inputSchema: {
            type: "object",
            properties: {
              path: { type: "string", description: "Directory path to list" }
            },
            required: ["path"]
          }
        }
      ]
    }));

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

      try {
        switch (name) {
          case "read_file":
            return await this.fileOps.readFile(args.path);
          case "write_file":
            return await this.fileOps.writeFile(args.path, args.content);
          case "list_directory":
            return await this.fileOps.listDirectory(args.path);
          default:
            throw new McpError(ErrorCode.MethodNotFound, `Tool ${name} not found`);
        }
      } catch (error) {
        throw new McpError(ErrorCode.InternalError, `Tool execution failed: ${error.message}`);
      }
    });
  }

  private setupErrorHandlers(): void {
    this.server.onerror = (error) => {
      console.error("[MCP Error]", error);
    };

    process.on('SIGINT', async () => {
      await this.server.close();
      process.exit(0);
    });
  }

  async start(): Promise {
    const transport = new StdioServerTransport();
    await this.server.connect(transport);
    console.error("File Server MCP started on stdio");
  }
}

export { FileServerMCP };

File Operations Implementation

The `tools/file-operations.ts` handles the actual file system work:

import * as fs from 'fs/promises';
import * as path from 'path';

export class FileOperations {
  private basePath: string;

  constructor(basePath: string = process.cwd()) {
    this.basePath = basePath;
  }

  private resolvePath(filePath: string): string {
    // Security: prevent directory traversal
    const resolved = path.resolve(this.basePath, filePath);
    if (!resolved.startsWith(this.basePath)) {
      throw new Error('Access denied: path outside allowed directory');
    }
    return resolved;
  }

  async readFile(filePath: string): Promise<{ content: string }> {
    try {
      const fullPath = this.resolvePath(filePath);
      const content = await fs.readFile(fullPath, 'utf-8');
      return {
        content: [
          {
            type: "text",
            text: `File: ${filePath}\n\n${content}`
          }
        ]
      };
    } catch (error) {
      return {
        content: [
          {
            type: "text",
            text: `Error reading file ${filePath}: ${error.message}`
          }
        ]
      };
    }
  }

  async writeFile(filePath: string, content: string): Promise<{ content: string }> {
    try {
      const fullPath = this.resolvePath(filePath);
      await fs.mkdir(path.dirname(fullPath), { recursive: true });
      await fs.writeFile(fullPath, content, 'utf-8');
      return {
        content: [
          {
            type: "text",
            text: `Successfully wrote ${content.length} characters to ${filePath}`
          }
        ]
      };
    } catch (error) {
      return {
        content: [
          {
            type: "text",
            text: `Error writing file ${filePath}: ${error.message}`
          }
        ]
      };
    }
  }

  async listDirectory(dirPath: string): Promise<{ content: string }> {
    try {
      const fullPath = this.resolvePath(dirPath);
      const entries = await fs.readdir(fullPath, { withFileTypes: true });
      
      const fileList = entries.map(entry => {
        const type = entry.isDirectory() ? '[DIR]' : '[FILE]';
        return `${type} ${entry.name}`;
      }).join('\n');

      return {
        content: [
          {
            type: "text",
            text: `Directory listing for ${dirPath}:\n\n${fileList}`
          }
        ]
      };
    } catch (error) {
      return {
        content: [
          {
            type: "text",
            text: `Error listing directory ${dirPath}: ${error.message}`
          }
        ]
      };
    }
  }
}

Entry Point

Your `src/index.ts` starts everything:

import { FileServerMCP } from './server.js';

async function main() {
  const server = new FileServerMCP();
  await server.start();
}

main().catch(console.error);

Configuration and Build Setup

TypeScript Configuration

Add `tsconfig.json`:

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

Package.json Scripts

Update your `package.json` scripts:

{
  "type": "module",
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js",
    "dev": "tsc && node dist/index.js",
    "clean": "rm -rf dist"
  }
}

Testing Your MCP Server

Build and Start

npm run build
npm run start

Your server should start and wait for JSON-RPC messages on stdin. You won't see much—that's normal. MCP servers are designed to be controlled by AI clients, not humans typing commands.

Testing with Claude Desktop

Claude Desktop is the easiest way to test MCP servers. Add this to your Claude config file (`~/Library/Application Support/Claude/claude_desktop_config.json` on Mac):

{
  "mcpServers": {
    "file-server": {
      "command": "node",
      "args": ["/path/to/your/mcp-file-server/dist/index.js"],
      "env": {
        "NODE_ENV": "production"
      }
    }
  }
}

Restart Claude Desktop. You should see a small plug icon indicating MCP tools are available.

Security Note: This gives Claude access to your file system. Start with a restricted directory for testing.

Production Deployment Considerations

Authentication and Security

Production MCP servers need proper auth. Add environment-based API keys:

// Add to your server constructor
private validateAuth(request: any): boolean {
  const apiKey = process.env.MCP_API_KEY;
  const requestKey = request.meta?.auth?.apiKey;
  
  return apiKey && requestKey === apiKey;
}

// Check auth in your tool handlers
if (!this.validateAuth(request)) {
  throw new McpError(ErrorCode.InvalidRequest, "Authentication required");
}

Rate Limiting

Prevent abuse with simple rate limiting:

class RateLimiter {
  private requests = new Map();
  
  isAllowed(clientId: string, limit: number = 100, windowMs: number = 60000): boolean {
    const now = Date.now();
    const requests = this.requests.get(clientId) || [];
    
    // Remove old requests
    const validRequests = requests.filter(time => now - time < windowMs);
    
    if (validRequests.length >= limit) return false;
    
    validRequests.push(now);
    this.requests.set(clientId, validRequests);
    return true;
  }
}

Logging and Monitoring

Add structured logging for production debugging:

private logRequest(tool: string, args: any, duration: number): void {
  console.error(JSON.stringify({
    timestamp: new Date().toISOString(),
    tool,
    args: JSON.stringify(args),
    duration,
    level: 'info'
  }));
}

Advanced Features

Tool Discovery

Make your tools discoverable by documenting their capabilities clearly:

// Enhanced tool description
{
  name: "search_files",
  description: "Search for files by content or filename pattern",
  inputSchema: {
    type: "object",
    properties: {
      query: { 
        type: "string", 
        description: "Search query (supports regex)" 
      },
      type: { 
        type: "string", 
        enum: ["content", "filename"], 
        description: "Search type" 
      },
      max_results: { 
        type: "number", 
        default: 10,
        description: "Maximum number of results" 
      }
    },
    required: ["query"],
    examples: [
      {
        query: "TODO|FIXME",
        type: "content",
        max_results: 5
      }
    ]
  }
}

Streaming Results

For large files or long operations, implement streaming:

async streamFile(filePath: string): Promise {
  const fullPath = this.resolvePath(filePath);
  const stream = fs.createReadStream(fullPath);
  
  return new ReadableStream({
    start(controller) {
      stream.on('data', (chunk) => {
        controller.enqueue(chunk.toString());
      });
      
      stream.on('end', () => {
        controller.close();
      });
    }
  });
}

Common Pitfalls and Solutions

Path Traversal Attacks

Always validate file paths. Our `resolvePath` method prevents `../../../etc/passwd` attacks, but double-check your validation logic.

Memory Issues with Large Files

Don't load entire files into memory. Use streams for files over 10MB:

async readLargeFile(filePath: string): Promise<{ content: string }> {
  const stats = await fs.stat(this.resolvePath(filePath));
  
  if (stats.size > 10 * 1024 * 1024) {
    return {
      content: [{
        type: "text", 
        text: `File too large (${stats.size} bytes). Use streaming endpoint.`
      }]
    };
  }
  
  // Normal read for smaller files
  return this.readFile(filePath);
}

Error Handling

MCP clients expect specific error formats. Always wrap your errors properly:

catch (error) {
  if (error.code === 'ENOENT') {
    throw new McpError(ErrorCode.InvalidParams, `File not found: ${filePath}`);
  }
  
  throw new McpError(ErrorCode.InternalError, `Unexpected error: ${error.message}`);
}

Next Steps

You now have a working MCP server that can read, write, and list files. This is just the foundation.

Consider extending it with:

  • Database connectivity (PostgreSQL, MongoDB)
  • API integration (REST, GraphQL endpoints)
  • Search capabilities (full-text search, vector search)
  • File processing (image analysis, document parsing)
  • Real-time subscriptions (WebSocket connections)

The Model Context Protocol specification is open and growing. Your MCP server can be the bridge that makes your data AI-accessible.

Build Context Architecture That Scales

MCP servers are one piece of the context puzzle. ContextArch helps you design the full architecture—from data ingestion to AI model consumption.

Design Your Context Architecture

Related