· mcp / claude-code / typescript
Cách xây dựng MCP server cho Claude Code
Dựng TypeScript MCP server với [email protected], đăng ký với Claude Code, và triển khai hai công cụ thời tiết NWS thực tế — không cần API key, xong trong 30 phút.
Bởi Ethan
2.140 từ · 11 phút đọc
MCP là abstraction phù hợp để kết nối Claude Code với bất kỳ API hay data source nào mà team bạn sở hữu. Tutorial này xây dựng một server hoạt động thực sự từ đầu: TypeScript, @modelcontextprotocol/[email protected], hai công cụ thời tiết thực tế gọi đến National Weather Service. Không có API trả phí, không cần API key. Kết thúc tutorial, Claude Code sẽ có thể gọi server của bạn ngay trên máy.
Bài này dành cho ai
Developer đang dùng Claude Code và muốn mở rộng nó bằng một công cụ tùy chỉnh — lấy dữ liệu nội bộ, truy vấn database, hay bọc một API sẵn có. Bạn cần Node.js 18+ và biết TypeScript ở mức cơ bản. Không cần biết gì về MCP trước khi đọc bài này.
MCP là gì
MCP (Model Context Protocol) là giao thức mã nguồn mở xây trên JSON-RPC 2.0, chuẩn hóa cách các AI host (Claude Code, Cursor, VS Code Copilot) giao tiếp với các nhà cung cấp khả năng bên ngoài. Một server phơi bày ba thứ: tools (các hàm model có thể gọi), resources (dữ liệu read-only gắn vào context), và prompts (template lệnh có thể tái sử dụng). Host tạo một MCP client cho mỗi server; giao thức xử lý việc thương lượng khả năng, discovery, gọi hàm, và báo lỗi mà host không cần biết gì về cách server triển khai bên trong. Khi server của bạn tồn tại, bất kỳ AI host nào tương thích MCP đều có thể dùng nó — đây là cổng USB-C cho các khả năng AI.
Tutorial này chỉ đề cập đến tools, vốn là nơi mang lại 90% giá trị.
Điều kiện cần
Bạn cần:
- Node.js 18+ —
node -vphải hiển thịv18trở lên - pnpm —
npm i -g pnpmnếu chưa có - TypeScript — cài theo project bên dưới, không cần cài global
Tạo một project:
mkdir weather-mcp && cd weather-mcp
pnpm init
pnpm add @modelcontextprotocol/[email protected] zod@3
pnpm add -D @types/node typescript
mkdir src && touch src/index.ts
package.json — thêm các field sau vào sau khi chạy pnpm init:
{
"type": "module",
"bin": { "weather-mcp": "./build/index.js" },
"scripts": {
"build": "tsc && chmod 755 build/index.js",
"dev": "tsc --watch"
}
}
tsconfig.json — Node16 là bắt buộc để resolve ESM import:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}
Lỗi thường gặp: Dùng "module": "CommonJS" ở đây sẽ làm hỏng các import extension .js từ SDK. Nếu bạn thấy ERR_REQUIRE_ESM hoặc không import được @modelcontextprotocol/sdk/server/mcp.js, hãy kiểm tra chỗ này trước.
Dựng server cơ bản
Dán đoạn sau vào src/index.ts:
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: "weather-mcp", version: "1.0.0" });
server.registerTool(
"greet",
{
description: "Greet someone by name",
inputSchema: { name: z.string().describe("Person's name") },
},
async ({ name }) => ({
content: [{ type: "text", text: `Hello, ${name}!` }],
}),
);
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("weather-mcp running on stdio");
}
main().catch((err) => {
console.error("Fatal:", err);
process.exit(1);
});
Lưu ý console.error, không phải console.log. Điều này rất quan trọng: server giao tiếp với Claude Code qua stdin/stdout. Bất kỳ output nào từ console.log sẽ làm hỏng kênh đó và ngắt kết nối một cách âm thầm. Hãy luôn gửi thông tin debug ra stderr.
Build:
pnpm build
File build/index.js sẽ xuất hiện. Kiểm tra thủ công:
echo '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1"}},"id":1}' | node build/index.js
Server sẽ phản hồi bằng một JSON-RPC initialize result. Nếu vậy, transport đã được kết nối đúng.
Đăng ký với Claude Code
Claude Code hỗ trợ ba scope:
| Scope | Vị trí config | Chia sẻ với team |
|---|---|---|
local (mặc định) | ~/.claude.json | Không |
project | .mcp.json ở thư mục gốc project | Có (commit vào repo) |
user | ~/.claude.json (global) | Không |
Để phát triển cục bộ, đăng ký với scope local:
claude mcp add --transport stdio weather-mcp -- node /absolute/path/to/weather-mcp/build/index.js
Thứ tự quan trọng: tất cả các flag (--transport, --scope, --env) phải đứng trước tên server. Dấu phân cách -- là bắt buộc trước lệnh khởi động server. Sai thứ tự sẽ sinh ra lỗi khó hiểu về unknown arguments.
Kiểm tra đăng ký:
claude mcp list
# weather-mcp stdio node /absolute/path/to/weather-mcp/build/index.js
Để chia sẻ theo project-scope (để mọi người trong team tự động nhận được server), tạo .mcp.json ở thư mục gốc của project:
{
"mcpServers": {
"weather-mcp": {
"command": "node",
"args": ["/absolute/path/to/weather-mcp/build/index.js"]
}
}
}
Hai quy tắc cho .mcp.json: luôn dùng absolute path (relative path có thể thất bại tùy vào ngữ cảnh spawn), và commit file này vào version control để đồng đội không phải đăng ký thủ công.
Để kiểm tra trạng thái server trong một phiên Claude Code, chạy /mcp. Lệnh này hiển thị mọi server đã đăng ký, trạng thái kết nối, và danh sách công cụ mà server đó phơi bày.
Triển khai công cụ thực tế
Công cụ greet chỉ để kiểm tra nhanh. Giờ hãy thay bằng hai công cụ thực: get_alerts và get_forecast, cả hai đều gọi đến API của US National Weather Service — miễn phí, không cần key.
Thay nội dung src/index.ts bằng:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const NWS_API_BASE = "https://api.weather.gov";
const USER_AGENT = "weather-mcp/1.0.0 ([email protected])";
const server = new McpServer({ name: "weather-mcp", version: "1.0.0" });
async function nwsFetch(path: string): Promise<unknown> {
const res = await fetch(`${NWS_API_BASE}${path}`, {
headers: {
"User-Agent": USER_AGENT,
Accept: "application/geo+json",
},
});
if (!res.ok) throw new Error(`NWS ${res.status}: ${res.statusText}`);
return res.json();
}
function errorResult(message: string) {
return { content: [{ type: "text" as const, text: message }], isError: true };
}
server.registerTool(
"get_alerts",
{
description: "Get active weather alerts for a US state",
inputSchema: {
state: z
.string()
.length(2)
.toUpperCase()
.describe("Two-letter US state code, e.g. CA"),
},
},
async ({ state }) => {
try {
const data = (await nwsFetch(`/alerts/active/area/${state}`)) as {
features: Array<{
properties: {
event?: string;
areaDesc?: string;
severity?: string;
description?: string;
};
}>;
};
if (!data.features.length) {
return { content: [{ type: "text", text: `No active alerts for ${state}.` }] };
}
const text = data.features
.slice(0, 5)
.map((f) => {
const p = f.properties;
return [
`Event: ${p.event ?? "Unknown"}`,
`Area: ${p.areaDesc ?? "Unknown"}`,
`Severity: ${p.severity ?? "Unknown"}`,
`Details: ${(p.description ?? "").slice(0, 200)}`,
].join("\n");
})
.join("\n\n---\n\n");
return { content: [{ type: "text", text }] };
} catch (err) {
return errorResult(`Failed to fetch alerts: ${(err as Error).message}`);
}
},
);
server.registerTool(
"get_forecast",
{
description: "Get the 5-period weather forecast for a latitude/longitude",
inputSchema: {
latitude: z.number().min(-90).max(90).describe("Latitude"),
longitude: z.number().min(-180).max(180).describe("Longitude"),
},
},
async ({ latitude, longitude }) => {
try {
const pointData = (await nwsFetch(
`/points/${latitude.toFixed(4)},${longitude.toFixed(4)}`,
)) as { properties: { forecast?: string } };
const forecastUrl = pointData.properties.forecast;
if (!forecastUrl) return errorResult("No forecast URL in NWS response.");
const path = new URL(forecastUrl).pathname;
const forecast = (await nwsFetch(path)) as {
properties: {
periods: Array<{
name: string;
temperature: number;
temperatureUnit: string;
windSpeed: string;
shortForecast: string;
}>;
};
};
const text = forecast.properties.periods
.slice(0, 5)
.map(
(p) =>
`${p.name}: ${p.temperature}°${p.temperatureUnit}, ${p.windSpeed}, ${p.shortForecast}`,
)
.join("\n");
return { content: [{ type: "text", text }] };
} catch (err) {
return errorResult(`Failed to fetch forecast: ${(err as Error).message}`);
}
},
);
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("weather-mcp running on stdio");
}
main().catch((err) => {
console.error("Fatal:", err);
process.exit(1);
});
Ba điều cần lưu ý:
isError: true trong result — không phải throw exception. Khi một tool call thất bại vì lý do nghiệp vụ (API ngừng hoạt động, input sai, rate limit), hãy trả về { content: [...], isError: true }. Điều này báo hiệu cho Claude Code rằng lệnh gọi thất bại nhưng server vẫn hoạt động bình thường. Model có thể thấy text lỗi và thử lại hoặc điều chỉnh. Một exception bị throw trở thành lỗi ở cấp giao thức, model không thể thấy — trông giống như server bị crash.
Zod để validate input — z.string().length(2).toUpperCase() đảm bảo bạn không bao giờ gọi NWS API với một state code sai định dạng. SDK kiểm tra schema trước khi handler của bạn chạy, nên không cần kiểm tra thêm bên trong hàm.
Số lượng tool — hai công cụ. Model xem xét mọi tool ở mỗi lượt; một server có ba mươi tool sẽ tăng độ trễ và tăng khả năng nó chọn sai. Chỉ phơi bày bề mặt tối thiểu cần thiết.
Build lại và Claude Code nhận ngay các tool mới — không cần đăng ký lại:
pnpm build
Kiểm thử và debug
Mở một phiên Claude Code và kiểm tra xem server đã kết nối chưa:
claude
> /mcp
● weather-mcp connected
Tools: get_alerts, get_forecast
Sau đó yêu cầu nó dùng một tool:
> What are the active weather alerts in California?
I'll check the current weather alerts for California using the weather-mcp server.
[Tool call: get_alerts({"state": "CA"})]
There are currently 2 active alerts for California:
Event: Wind Advisory
Area: Inland Empire, San Bernardino County Mountains
Severity: Moderate
Details: WIND ADVISORY IN EFFECT FROM 6 PM THIS EVENING THROUGH 6 AM PDT THURSDAY...
---
Event: Small Craft Advisory
Area: Santa Monica Bay
Severity: Minor
Details: SMALL CRAFT ADVISORY IN EFFECT THROUGH FRIDAY 6 PM PDT...
Nếu server không xuất hiện trong /mcp, hãy kiểm tra hai thứ trước: liệu build path trong lệnh đăng ký có phải absolute path không, và liệu có console.log nào trong code bạn bỏ sót không. Cả hai đều gây ra lỗi âm thầm.
Hai loại lỗi — và chúng hoạt động khác nhau:
| Loại | Model thấy gì | Khi nào xảy ra |
|---|---|---|
Lỗi giao thức (trường error JSON-RPC) | Không có gì — trông như server crash | Tool không tồn tại, schema sai, exception không được bắt |
Lỗi thực thi tool (isError: true) | Text lỗi bạn trả về | API lỗi, input sai, lỗi logic nghiệp vụ |
Nếu Claude Code nói “I was unable to call the tool” mà không có thông tin gì thêm, đó là lỗi giao thức — xem trong ~/.claude/logs/ để tìm JSON-RPC trace thô.
MCP Inspector là cách nhanh nhất để debug tool schema trước khi kết nối Claude Code:
npx @modelcontextprotocol/inspector node build/index.js
Nó mở một giao diện trình duyệt cho phép bạn gọi tool trực tiếp, kiểm tra schema mà Claude Code sẽ thấy, và theo dõi các JSON-RPC message theo thời gian thực.
Danh sách lỗi thường gặp:
console.logở bất kỳ đâu trong server code → làm hỏng stdio → mất kết nối âm thầmoneOf/allOf/anyOftronginputSchema→ bị Claude Code từ chối (không hỗ trợ trong MCP tool schema)- Tên tool chứa ký tự ngoài
^[a-zA-Z0-9_-]{1,64}$→ server bị bỏ qua kèm cảnh báo - Tên server
workspace→ bị đặt trước; Claude Code bỏ qua âm thầm - Relative path trong
.mcp.json→ thất bại tùy ngữ cảnh spawn; luôn dùng absolute path - Output tool vượt quá 10.000 token → hiển thị cảnh báo; giới hạn mặc định là 25.000 token (đặt
MAX_MCP_OUTPUT_TOKENSđể tăng)
Tiếp theo là gì
Repo reference servers chính thức (85.500+ sao) có các triển khai mẫu cho Filesystem, Git, Fetch, Memory, và nhiều hơn nữa — được duy trì bởi nhóm điều hành MCP làm ví dụ học thuật. Đọc chúng nhanh hơn đọc spec.
Anthropic duy trì một registry cộng đồng cho các MCP server bên thứ ba.
Hai điều ở cấp spec đáng biết khi bạn vượt qua tutorial này:
Resources — để gắn dữ liệu read-only vào context thay vì gọi một hàm. Hữu ích khi cần phơi bày tài liệu, cấu hình, hoặc database schema cho model mà không biến nó thành một tool call chủ động.
Streamable HTTP transport — dành cho server từ xa mà team bạn chia sẻ qua mạng. Pattern giống với stdio; bạn đổi StdioServerTransport bằng một HTTP transport instance. OAuth 2.0 được tích hợp sẵn trong giao thức để xác thực.
SDK v2 alpha (@modelcontextprotocol/[email protected]) đã có sẵn nhưng chưa ổn định cho production. Surface v1 trong tutorial này là thứ Claude Code đang dùng hiện nay.
Nếu bạn đang xây dựng trên chính Claude Code — không chỉ mở rộng nó — bài so sánh claude-code-vs-codex và tổng hợp best AI coding CLI là bối cảnh hữu ích để hiểu vị trí của nó trong hệ sinh thái.