How to Build Your Own MCP Server (Complete Tutorial)
From scratch, implement a production-ready custom MCP Server in 30 minutes
How to Build Your Own MCP Server (Complete Tutorial)
Prerequisites: Node.js 18+, basic TypeScript/JavaScript knowledge
Why Do You Need a Custom MCP Server?
The existing 500+ MCP Servers cover most common scenarios, but you might need to:
These scenarios require you to write your own MCP Server. The good news is: the MCP SDK is designed to be very concise—writing a basic server takes only ~50 lines of code.
Core Concepts of MCP Server
An MCP Server can expose three types of capabilities:
This tutorial focuses on the most commonly used Tools.
Step 1: Initialize the Project
bash
mkdir my-mcp-server && cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx
Create tsconfig.json:
json
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"strict": true
}
}
Update package.json:
json
{
"type": "module",
"bin": { "my-mcp-server": "./dist/index.js" },
"scripts": {
"build": "tsc",
"dev": "tsx src/index.ts"
}
}
Step 2: Implement a Minimal MCP Server
Create src/index.ts:
typescript
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';// 1. Create Server instance
const server = new McpServer({
name: 'my-mcp-server',
version: '1.0.0'
});
// 2. Register a tool
server.tool(
'get_weather', // Tool name (AI will use this to call)
'Get real-time weather for a specified city', // Tool description (AI uses to decide when to call)
{
city: z.string().describe('City name, e.g., "Beijing", "Shanghai"')
},
async ({ city }) => {
// Call your actual API here
const res = await fetch(https://api.weather.example.com/v1/current?city=${city});
const data = await res.json();
return {
content: [{
type: 'text',
text: Current weather in ${city}: ${data.condition}, temperature ${data.temp}°C, humidity ${data.humidity}%
}]
};
}
);
// 3. Start the Server
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('MCP Server started'); // Note: logs should go to stderr, stdout is for protocol communication
Step 3: Add Multiple Tools (Practical Example)
Below is a practical example of querying internal company data:
typescript
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';const server = new McpServer({
name: 'company-internal-api',
version: '1.0.0',
description: 'Company internal API integration MCP Server'
});
const API_BASE = process.env.INTERNAL_API_URL ?? 'https://api.company.internal';
const API_TOKEN = process.env.INTERNAL_API_TOKEN ?? '';
// Helper function: fetch with authentication
async function apiFetch(path: string, options?: RequestInit) {
const res = await fetch(${API_BASE}${path}, {
...options,
headers: {
'Authorization': Bearer ${API_TOKEN},
'Content-Type': 'application/json',
...options?.headers
}
});
if (!res.ok) throw new Error(API error ${res.status}: ${await res.text()});
return res.json();
}
// Tool 1: Query customer information
server.tool(
'get_customer',
'Query detailed customer information by customer ID or name',
{
query: z.string().describe('Customer ID (e.g., C001) or customer name'),
include_orders: z.boolean().optional().default(false).describe('Whether to include recent orders')
},
async ({ query, include_orders }) => {
const customer = await apiFetch(/customers/search?q=${encodeURIComponent(query)});
let result = `Customer Info:
Name: ${customer.name}
Company: ${customer.company}
Contact: ${customer.email} / ${customer.phone}
Tier: ${customer.tier}
Registration Date: ${customer.created_at}`; if (include_orders && customer.id) {
const orders = await apiFetch(/customers/${customer.id}/orders?limit=5);
result += `
Recent 5 Orders:
${orders.map((o: any) =>
- #${o.id} ${o.created_at}: ${o.amount} yuan (${o.status})
).join('\n')}`;
}
return { content: [{ type: 'text', text: result }] };
}
);
// Tool 2: Check inventory
server.tool(
'check_inventory',
'Check stock quantity and estimated arrival time for a specified product',
{
sku: z.string().describe('Product SKU'),
warehouse: z.enum(['beijing', 'shanghai', 'guangzhou', 'all']).default('all').describe('Warehouse to query')
},
async ({ sku, warehouse }) => {
const inventory = await apiFetch(/inventory/${sku}?warehouse=${warehouse});
const lines = warehouse === 'all'
? inventory.warehouses.map((w: any) => - ${w.name}: ${w.stock} units)
: [- Current stock: ${inventory.stock} units];
return {
content: [{
type: 'text',
text: `Inventory for product ${sku}:
${lines.join('\n')}
Total stock: ${inventory.total} units
Estimated restock date: ${inventory.restock_date ?? 'No plan yet'}`
}]
};
}
);
// Tool 3: Create a ticket
server.tool(
'create_ticket',
'Create a customer service ticket, suitable for issues requiring human follow-up',
{
customer_id: z.string().describe('Customer ID'),
category: z.enum(['refund', 'delivery', 'product', 'other']).describe('Ticket category'),
description: z.string().min(10).describe('Issue description (at least 10 characters)'),
priority: z.enum(['low', 'normal', 'high', 'urgent']).default('normal')
},
async ({ customer_id, category, description, priority }) => {
const ticket = await apiFetch('/tickets', {
method: 'POST',
body: JSON.stringify({ customer_id, category, description, priority })
});
return {
content: [{
type: 'text',
text: `✅ Ticket created successfully!
Ticket ID: ${ticket.id}
Priority: ${priority}
Expected response time: ${ticket.sla_deadline}
Assigned to: ${ticket.assigned_to ?? 'Pending assignment'}`
}]
};
}
);const transport = new StdioServerTransport();
await server.connect(transport);
Step 4: Error Handling Best Practices
typescript
// ✅ Good error handling: provide AI-friendly error messages
server.tool('get_customer', '...', { query: z.string() }, async ({ query }) => {
try {
const data = await apiFetch(/customers/search?q=${query});
if (!data || data.length === 0) {
return {
content: [{ type: 'text', text: No customer found matching "${query}". Please check the spelling or try other keywords. }],
isError: true
};
}
return { content: [{ type: 'text', text: formatCustomer(data[0]) }] };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
content: [{ type: 'text', text: Query failed: ${message}. Please try again later. }],
isError: true // Mark as error, AI will know this call failed
};
}
});
Step 5: Test in Claude Desktop
npm run build~/Library/Application Support/Claude/claude_desktop_config.json):json
{
"mcpServers": {
"company-api": {
"command": "node",
"args": ["/absolute/path/my-mcp-server/dist/index.js"],
"env": {
"INTERNAL_API_URL": "https://api.company.internal",
"INTERNAL_API_TOKEN": "your-token-here"
}
}
}
}
Step 6: Publish as an npm Package (Optional)
If you want your team members to use it too:
bash
Modify package.json
{
"name": "@your-org/mcp-server-company",
"version": "1.0.0",
"bin": { "mcp-server-company": "./dist/index.js" }
}npm publish --access restricted # Publish to private npm Registry
Team member configuration:
json
{
"mcpServers": {
"company-api": {
"command": "npx",
"args": ["@your-org/mcp-server-company"],
"env": { "INTERNAL_API_TOKEN": "..." }
}
}
}
Production Deployment Considerations
console.error to log to stderr for debuggingFAQ
Q: Does MCP Server have to be written in TypeScript?
A: No, the official Python SDK (mcp package) is available, and a Go SDK is under development.
Q: Can MCP Server handle concurrent requests? A: stdio mode is serial (one request at a time). For high concurrency, you can use SSE (Server-Sent Events) mode.
Q: How to debug an MCP Server? A: We recommend MCP Inspector, the official visual debugging tool that allows you to manually test tool calls.
bash
npx @modelcontextprotocol/inspector dist/index.js
Open browser at http://localhost:5173
Also available in 中文.