Xây dựng MCP Server đầu tiên — Hướng dẫn step-by-step
Giới thiệu
Sau khi đã hiểu MCP là gì, bước tiếp theo tự nhiên là xây dựng MCP Server riêng. Đây là cách bạn tạo custom tools cho Claude Code và Claude Desktop — biến Claude thành một AI agent có thể thao tác trực tiếp với systems của bạn.
Trong bài này, chúng ta sẽ xây dựng một MCP Server thực tế bằng TypeScript: bắt đầu từ server đọc file đơn giản, sau đó mở rộng thành một weather API server hoàn chỉnh.
Prerequisites
Yêu cầu môi trường
-
Node.js 18+ — kiểm tra:
node --version - npm hoặc pnpm
-
TypeScript 5+ — install global:
npm install -g typescript - Claude Desktop hoặc Claude Code để test
Hiểu cơ bản về TypeScript và async/await
Bài hướng dẫn này giả định bạn đã quen với TypeScript cơ bản và async programming. Nếu chưa, hãy xem qua TypeScript handbook trước.
Setup project
Khởi tạo project
# Tạo thư mục project
mkdir my-mcp-server
cd my-mcp-server
# Khởi tạo npm project
npm init -y
# Cài MCP SDK và dependencies
npm install @modelcontextprotocol/sdk zod
# Cài dev dependencies
npm install -D typescript @types/node tsx
# Tạo tsconfig
npx tsc --init
Cập nhật tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
Cập nhật package.json:
{
"name": "my-mcp-server",
"version": "1.0.0",
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"dev": "tsx src/index.ts",
"start": "node dist/index.js"
}
}
MCP Server đầu tiên — Đọc file
Tạo server cơ bản
Tạo file src/index.ts:
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 { z } from "zod";
import { readFileSync, existsSync } from "fs";
import { resolve } from "path";
// Khởi tạo server với metadata
const server = new Server(
{
name: "my-file-server",
version: "1.0.0",
},
{
capabilities: {
tools: {}, // Server này cung cấp tools
},
}
);
// Định nghĩa danh sách tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "read_file",
description: "Đọc nội dung của một file text",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
description: "Đường dẫn tuyệt đối đến file cần đọc",
},
},
required: ["path"],
},
},
{
name: "check_file_exists",
description: "Kiểm tra file có tồn tại hay không",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
description: "Đường dẫn đến file cần kiểm tra",
},
},
required: ["path"],
},
},
],
};
});
// Xử lý tool calls
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
if (name === "read_file") {
const { path } = z.object({ path: z.string() }).parse(args);
const absolutePath = resolve(path);
if (!existsSync(absolutePath)) {
return {
content: [
{
type: "text",
text: `Lỗi: File không tồn tại: ${absolutePath}`,
},
],
isError: true,
};
}
try {
const content = readFileSync(absolutePath, "utf-8");
return {
content: [
{
type: "text",
text: content,
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Lỗi đọc file: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
};
}
}
if (name === "check_file_exists") {
const { path } = z.object({ path: z.string() }).parse(args);
const absolutePath = resolve(path);
const exists = existsSync(absolutePath);
return {
content: [
{
type: "text",
text: exists
? `File tồn tại: ${absolutePath}`
: `File không tồn tại: ${absolutePath}`,
},
],
};
}
return {
content: [{ type: "text", text: `Tool không tồn tại: ${name}` }],
isError: true,
};
});
// Khởi động server với stdio transport
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("MCP File Server đã khởi động"); // Log ra stderr
}
main().catch(console.error);
Build và test
# Build TypeScript
npm run build
# Test thủ công
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | node dist/index.js
Đăng ký server với Claude Code
# Thêm server vào Claude Code
claude mcp add my-file-server node /absolute/path/to/my-mcp-server/dist/index.js
# Kiểm tra server đã được thêm
claude mcp list
# Test trong Claude Code
claude "Đọc file /etc/hosts và tóm tắt nội dung"
Đăng ký server với Claude Desktop
Thêm vào claude_desktop_config.json:
{
"mcpServers": {
"my-file-server": {
"command": "node",
"args": ["/absolute/path/to/my-mcp-server/dist/index.js"]
}
}
}
Restart Claude Desktop. Bạn sẽ thấy tools read_file và check_file_exists xuất hiện trong danh sách available tools.
Thêm Resources
Ngoài Tools, MCP Server có thể expose Resources — dữ liệu mà Claude có thể đọc. Thêm resource support vào server:
import {
ListResourcesRequestSchema,
ReadResourceRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
// Khai báo capabilities bao gồm resources
const server = new Server(
{ name: "my-file-server", version: "1.0.0" },
{
capabilities: {
tools: {},
resources: {}, // Thêm resources capability
},
}
);
// Handler cho list resources
server.setRequestHandler(ListResourcesRequestSchema, async () => {
return {
resources: [
{
uri: "file:///var/log/app.log",
name: "Application Log",
description: "Log file của ứng dụng",
mimeType: "text/plain",
},
],
};
});
// Handler cho read resource
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const { uri } = request.params;
if (uri === "file:///var/log/app.log") {
const content = readFileSync("/var/log/app.log", "utf-8");
return {
contents: [
{
uri,
mimeType: "text/plain",
text: content,
},
],
};
}
throw new Error(`Resource không tồn tại: ${uri}`);
});
Ví dụ thực tế — Weather API Server
Bây giờ hãy build một server thực tế hơn: gọi weather API và trả kết quả cho Claude.
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 { z } from "zod";
const server = new Server(
{ name: "weather-server", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "get_weather",
description: "Lấy thông tin thời tiết hiện tại cho một thành phố",
inputSchema: {
type: "object",
properties: {
city: {
type: "string",
description: "Tên thành phố (tiếng Anh), ví dụ: Hanoi, Ho Chi Minh City",
},
units: {
type: "string",
enum: ["metric", "imperial"],
description: "Đơn vị nhiệt độ: metric (Celsius) hoặc imperial (Fahrenheit)",
default: "metric",
},
},
required: ["city"],
},
},
],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
if (name === "get_weather") {
const { city, units = "metric" } = z
.object({
city: z.string(),
units: z.enum(["metric", "imperial"]).optional().default("metric"),
})
.parse(args);
const apiKey = process.env.OPENWEATHER_API_KEY;
if (!apiKey) {
return {
content: [
{ type: "text", text: "Lỗi: OPENWEATHER_API_KEY chưa được cấu hình" },
],
isError: true,
};
}
try {
const url = `https://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent(city)}&units=${units}&appid=${apiKey}`;
const response = await fetch(url);
if (!response.ok) {
const error = await response.json() as { message?: string };
return {
content: [
{
type: "text",
text: `Lỗi từ API: ${error.message || response.statusText}`,
},
],
isError: true,
};
}
const data = await response.json() as {
name: string;
sys: { country: string };
main: { temp: number; feels_like: number; humidity: number };
weather: Array<{ description: string }>;
wind: { speed: number };
};
const tempUnit = units === "metric" ? "°C" : "°F";
const windUnit = units === "metric" ? "m/s" : "mph";
const summary = [
`Thời tiết tại ${data.name}, ${data.sys.country}:`,
`- Nhiệt độ: ${data.main.temp}${tempUnit} (cảm giác như ${data.main.feels_like}${tempUnit})`,
`- Điều kiện: ${data.weather[0].description}`,
`- Độ ẩm: ${data.main.humidity}%`,
`- Gió: ${data.wind.speed} ${windUnit}`,
].join("\n");
return {
content: [{ type: "text", text: summary }],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Lỗi kết nối: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
};
}
}
return {
content: [{ type: "text", text: `Tool không tồn tại: ${name}` }],
isError: true,
};
});
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Weather MCP Server đã khởi động");
}
main().catch(console.error);
Đăng ký với Claude Desktop:
{
"mcpServers": {
"weather": {
"command": "node",
"args": ["/path/to/weather-server/dist/index.js"],
"env": {
"OPENWEATHER_API_KEY": "your_api_key_here"
}
}
}
}
Sau khi cấu hình, bạn có thể hỏi Claude: "Thời tiết Hà Nội hôm nay thế nào?" và Claude sẽ gọi tool để lấy dữ liệu thực.
Testing MCP Server
Unit testing handlers
Test handlers riêng biệt mà không cần khởi động full server:
// tests/handlers.test.ts
import { describe, it, expect } from "vitest";
import { readFileSync, writeFileSync, unlinkSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
describe("read_file tool", () => {
it("đọc file thành công", async () => {
// Tạo temp file
const tmpPath = join(tmpdir(), "test-mcp.txt");
writeFileSync(tmpPath, "Hello MCP World");
// Import và test handler function trực tiếp
const content = readFileSync(tmpPath, "utf-8");
expect(content).toBe("Hello MCP World");
// Cleanup
unlinkSync(tmpPath);
});
it("trả về lỗi khi file không tồn tại", () => {
const fakePath = "/nonexistent/path/file.txt";
const { existsSync } = require("fs");
expect(existsSync(fakePath)).toBe(false);
});
});
Integration testing với MCP Inspector
Anthropic cung cấp MCP Inspector — tool để test MCP servers interactively:
# Cài MCP Inspector
npm install -g @modelcontextprotocol/inspector
# Chạy inspector với server của bạn
npx @modelcontextprotocol/inspector node dist/index.js
Inspector mở giao diện web tại localhost:5173, cho phép:
- Xem danh sách tools, resources, prompts server expose
- Gọi tool với custom input và xem response
- Debug message exchange giữa client và server
Testing với Claude Code trực tiếp
Cách test nhanh nhất là thêm server vào Claude Code và test bằng ngôn ngữ tự nhiên:
# Thêm server đang develop (dùng tsx để không cần build)
claude mcp add my-server tsx /path/to/server/src/index.ts
# Test
claude "Dùng tool read_file để đọc /etc/hostname"
claude "Check xem file /tmp/test.txt có tồn tại không"
Thêm Prompts vào MCP Server
Prompts là gì và khi nào dùng
Ngoài Tools và Resources, MCP Server có thể cung cấp Prompts — template workflows được định nghĩa sẵn. User có thể invoke prompt bằng slash commands trong supported clients.
import {
ListPromptsRequestSchema,
GetPromptRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
server.setRequestHandler(ListPromptsRequestSchema, async () => ({
prompts: [
{
name: "analyze_file",
description: "Phân tích một file và đưa ra nhận xét",
arguments: [
{
name: "filepath",
description: "Đường dẫn đến file cần phân tích",
required: true,
},
],
},
],
}));
server.setRequestHandler(GetPromptRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
if (name === "analyze_file") {
const filepath = args?.filepath as string;
return {
description: "Phân tích file",
messages: [
{
role: "user",
content: {
type: "text",
text: `Hãy đọc file ${filepath} và phân tích:
1. Mục đích của file
2. Cấu trúc và tổ chức
3. Điểm mạnh trong code/content
4. Điểm cần cải thiện
5. Đề xuất cụ thể`,
},
},
],
};
}
throw new Error(`Prompt không tồn tại: ${name}`);
});
Error Handling tốt trong MCP Server
Một MCP Server production-ready cần handle errors rõ ràng:
- Validation errors: Dùng zod để validate input, throw với message rõ ràng
- External API errors: Catch và wrap với context hữu ích
-
isError flag: Set
isError: truetrong response khi có lỗi để Claude biết -
Logging: Log ra
stderr(không phải stdout) vì stdout dùng cho MCP protocol
Publishing MCP Server
Để share server với cộng đồng:
- Publish lên npm:
npm publish - Đặt tên convention:
mcp-server-[tên]hoặc@scope/mcp-server-[tên] - Thêm README với hướng dẫn cài đặt rõ ràng
- Submit lên awesome-mcp-servers repository trên GitHub
Best Practices khi build MCP Server
Idempotency và side effects
Thiết kế tools với tư duy rõ ràng về side effects:
-
Read-only tools: Không có side effects, safe to call nhiều lần. Ví dụ:
read_file,search_database,get_weather -
Mutating tools: Có side effects, nên có confirmation step hoặc dry-run mode. Ví dụ:
write_file,send_email,delete_record
Đặt tên tools phản ánh rõ ràng liệu chúng có destructive hay không:
// Rõ ràng
read_file // Read-only, safe
create_file // Creates new file
overwrite_file // Destructive, cần cẩn thận
delete_file // Destructive, cần confirm
Tool descriptions chất lượng cao
Claude quyết định khi nào gọi tool dựa trên description. Description tốt dẫn đến usage đúng; description kém dẫn đến wrong tool calls:
// BAD - quá chung chung
{
name: "process",
description: "Xử lý data",
}
// GOOD - cụ thể và có context
{
name: "analyze_csv_file",
description: "Đọc và phân tích file CSV. Trả về: số rows, column names, sample data (5 rows đầu), và basic statistics (min/max/mean cho numeric columns). Dùng khi user muốn hiểu cấu trúc hoặc nội dung của file CSV.",
}
Input validation chặt chẽ
Dùng zod hoặc JSON Schema validation cho mọi input. Đừng trust input từ AI model:
import { z } from "zod";
const ReadFileInput = z.object({
path: z
.string()
.min(1)
.refine((p) => !p.includes(".."), "Path traversal không được phép")
.refine((p) => p.startsWith("/allowed/"), "Chỉ đọc trong thư mục được phép"),
});
// Trong handler
try {
const { path } = ReadFileInput.parse(args);
// safe to proceed
} catch (error) {
if (error instanceof z.ZodError) {
return {
content: [{ type: "text", text: `Input không hợp lệ: ${error.errors.map(e => e.message).join(", ")}` }],
isError: true,
};
}
throw error;
}
Rate limiting cho external APIs
Nếu tools của bạn gọi external APIs, implement rate limiting trong server để tránh bị block:
import Bottleneck from "bottleneck";
// Giới hạn 10 requests/giây cho external API
const limiter = new Bottleneck({
maxConcurrent: 1,
minTime: 100, // 100ms giữa các requests
});
async function callExternalAPI(params: any) {
return limiter.schedule(() => fetch("https://api.example.com/...", params));
}
Deploying MCP Server
Local development server
Trong quá trình development, dùng tsx để không cần rebuild mỗi lần thay đổi:
# Thêm vào Claude Code với tsx (auto-reload khi file thay đổi)
claude mcp add my-server tsx /path/to/server/src/index.ts
# Sau khi build production
claude mcp add my-server node /path/to/server/dist/index.js
Packaging và distribution
Để share server với team hoặc cộng đồng:
// package.json — setup để chạy trực tiếp qua npx
{
"name": "@yourorg/mcp-server-myapp",
"version": "1.0.0",
"bin": {
"mcp-server-myapp": "./dist/index.js"
},
"files": ["dist/"],
"scripts": {
"build": "tsc",
"prepublishOnly": "npm run build"
}
}
Sau khi publish, users chỉ cần:
{
"mcpServers": {
"myapp": {
"command": "npx",
"args": ["-y", "@yourorg/mcp-server-myapp"]
}
}
}
Kết luận
Xây dựng MCP Server không phức tạp như bạn nghĩ. Với MCP SDK, bạn chỉ cần định nghĩa tools và handlers — SDK lo phần còn lại (protocol, transport, serialization).
Bắt đầu với server đọc file đơn giản, test với Claude Code, rồi mở rộng dần. Khi đã hiểu pattern cơ bản, việc thêm tools mới chỉ là thêm entry vào tools array và thêm case vào handler.
Custom MCP Server là cách mạnh nhất để tích hợp Claude vào existing workflow của bạn — biến Claude từ chat tool thành một agent thực sự làm việc với systems của bạn.
Bai viet co huu ich khong?
Bản quyền thuộc về tác giả. Vui lòng dẫn nguồn khi chia sẻ.






