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:
- Node.js 18+: Download from nodejs.org
- npm or yarn: Package manager (comes with Node.js)
- TypeScript: We'll install this as part of our setup
- A code editor: VS Code, WebStorm, or your preferred IDE
- Basic JavaScript/TypeScript knowledge: Understanding of async/await, modules, and basic concepts
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:
- Core MCP Functionality: Tools with proper schemas
- Security: Path validation and sandboxing
- Error Handling: Comprehensive error catching
- Production Ready: Docker support and deployment scripts
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!