MCP trong thực tế: xây một AI Agent tổ chức file
Giới thiệu
Một LLM có thể trả lời câu hỏi, nhưng không tự biết cách đọc một thư mục hay gọi API nội bộ. Mỗi khả năng cần một điểm tích hợp để mô tả tool, kiểm tra input, gọi hệ thống thật và đưa kết quả trở lại cuộc hội thoại. Nếu mỗi ứng dụng AI tự kết nối với từng hệ thống, n ứng dụng và m hệ thống có thể tạo ra tới n × m adapter phải viết và bảo trì.

Sơ đồ tổng quan về cách MCP kết nối AI Application với các hệ thống bên ngoài: API, Slack, database, GitHub, Gmail, file system.
Model Context Protocol (MCP) đặt ra một hợp đồng chung giữa hai phía. MCP Server công bố khả năng cùng schema input/output; ứng dụng AI tương thích có thể khám phá và gọi chúng theo cùng một giao thức. Business logic và cách gọi API thật nằm trên server. Host quyết định tool nào được đưa cho model và cách xin xác nhận; server vẫn phải kiểm tra quyền đối với thao tác nó nhận được. Tài liệu của MCP ví cách chuẩn hóa kết nối này giống với USB-C.
Bài này kiểm tra ý tưởng đó bằng một demo chạy được: một agent dùng hai tool MCP để tổ chức 500 file mock theo phần mở rộng và ngày sửa đổi, mà không đọc nội dung bên trong file.
MCP là gì: một giao thức, không phải một AI model
MCP là một đặc tả mở cho message trao đổi giữa ứng dụng AI và chương trình cung cấp dữ liệu hoặc hành động. Nó chuẩn hóa cách hai phía thỏa thuận phiên bản, công bố khả năng, mô tả input bằng schema, gọi tool và trả kết quả. MCP không thay thế API nghiệp vụ, không tự cấp quyền và cũng không quyết định tool nào nên được gọi.
Trong demo tổ chức file, một lượt làm việc diễn ra như sau:
- Người dùng nhập yêu cầu vào ứng dụng AI, tức MCP Host.
- Host tạo một MCP Client dành riêng cho File MCP Server và thiết lập kết nối.
- Client lấy danh sách tool từ server qua tools/list; host chọn tool nào được đưa vào request gửi model.
- Khi model chọn một tool, host gửi tools/call qua client. Server kiểm tra input, thao tác trên file system và trả kết quả.
- Host đưa kết quả của tool trở lại cuộc hội thoại để model quyết định bước tiếp theo.
Ba vai trò trong kiến trúc vì thế có ranh giới khá cụ thể. Host là ứng dụng người dùng đang mở, chẳng hạn Claude Desktop, Claude Code hoặc một IDE. Client là thành phần giao thức do host tạo ra. Mỗi client giữ một kết nối 1-1 với một server. Server là chương trình công bố khả năng và thực thi phần việc thật.
Vì sao dùng MCP thay vì tích hợp API tùy chỉnh
So sánh trực quan nhất nằm ở số lần phải tích hợp.

So sánh số điểm tích hợp: tích hợp tùy chỉnh cần tối đa n × m adapter; MCP đưa chi phí gần về n + m.
Với cách làm tùy chỉnh, mỗi ứng dụng AI có thể cần một adapter cho từng API. Trong mô hình đơn giản, n ứng dụng và m API tạo ra tối đa n × m điểm tích hợp. Với MCP, mỗi hệ thống công bố một server theo chuẩn chung, còn mỗi host triển khai client một lần. Khi các thành phần thực sự dùng lại được, chi phí tích hợp gần với n + m hơn. Đây là cách ước lượng kiến trúc, không phải cam kết rằng mọi API chỉ cần đúng một server.
Đi kèm là vài lợi ích cụ thể:
- Server định nghĩa schema, kiểm tra input và thực thi business logic một lần cho mọi host kết nối tới nó.
- Host hoặc adapter của model chuyển tool contract và kết quả sang định dạng mà provider yêu cầu.
- Một MCP Server có thể phục vụ nhiều MCP Client thay vì gắn code gọi API vào một agent framework cụ thể.
- Client khám phá tool hoặc resource tại runtime thay vì hard-code toàn bộ danh sách vào prompt.
- Model chỉ thấy những tool mà host chọn đưa vào request. Giao diện xác nhận và permission policy phụ thuộc vào host, transport và cấu hình server.
Use case minh họa: một Inbox không dọn suốt một năm
Hình dung một thư mục không ai dọn trong cả năm trời — có thể là thư mục thật trên máy bạn: ảnh chụp màn hình, file Word họp hành, hóa đơn PDF, báo cáo doanh thu, vài file Excel, tất cả nằm lẫn lộn. Không có agent, việc tổ chức lại đống này chỉ có hai lựa chọn: kéo-thả bằng tay từng file, hoặc viết một script phân loại cứng theo rule cố định (nếu đuôi file là X thì chuyển vào thư mục Y). Script đó vừa phải sửa lại mỗi khi thêm loại file mới, vừa khóa cứng vào một thư mục duy nhất — muốn dùng cho thư mục khác lại phải sửa code.
Demo trong bài dựng đúng tình huống đó: một script sinh 500 file mock vào một folder, ngày sửa đổi của mỗi file rải ngẫu nhiên trong 365 ngày gần nhất, mô phỏng một năm không dọn dẹp.
Người dùng đưa ra một câu lệnh tự nhiên kèm đường dẫn cần tổ chức. Agent lấy danh sách file rồi gọi move_files; server tự chọn thư mục đích theo phần mở rộng và ngày sửa đổi, xếp từng file theo loại rồi theo năm/tháng/ngày.

storage/inbox trước khi xử lý: 500 file mock, nhiều loại đuôi lẫn lộn.
Kiến trúc

Luồng gọi trong demo: User → Custom MCP Host/Agent (Gemini + MCP Client) → File MCP Server (list_files, move_files) → Local File Storage.
client.ts là custom host/agent application. Biến mcpClient bên trong nó giữ kết nối giao thức tới server; Gemini là model chọn tool và argument. File MCP Server liệt kê hoặc di chuyển file, còn file system lưu dữ liệu thật.
Server không có tool đọc nội dung file, nên nội dung hóa đơn, ảnh hoặc tài liệu không được gửi cho model. Phần lộ ra cho model chỉ là tên file, phần mở rộng và ngày sửa đổi.
Xây dựng MCP Server: hai tool list_files, move_files — không đọc nội dung file
Server đăng ký hai tool. Snippet đầu giữ phần khai báo schema và handler của list_files; các helper kiểm tra đường dẫn và đọc metadata nằm trong source đầy đủ.
// server/server.ts
import { McpServer } from "@modelcontextprotocol/server";
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import { z } from "zod";
import fs from "node:fs/promises";
import path from "node:path";
const server = new McpServer({ name: "file-organizer", version: "1.0.0" });
server.registerTool("list_files", {
description: "List direct child files and their modification dates.",
inputSchema: z.object({ folder: z.string() }),
}, async ({ folder }) => {
const root = resolveFolder(folder);
const entries = await fs.readdir(root, { withFileTypes: true });
const files = await Promise.all(entries.filter((entry) => entry.isFile()).map(async (entry) => ({
name: entry.name,
extension: extensionOf(entry.name),
modified: isoDateOf((await fs.stat(path.join(root, entry.name))).mtime),
})));
return { content: [{ type: "text", text: JSON.stringify(files) }] };
});
move_files nhận một batch. to_folder là optional: bỏ trống thì server tự tính <extension>/<year>/<month>/<day> từ ngày sửa đổi của chính file đó, nên tiêu chí phân loại mặc định vẫn đúng dù câu lệnh của người dùng không nói ra. moveOneFile trong source đầy đủ thực hiện validation, tạo hard link theo cơ chế không ghi đè rồi xóa source; lỗi của một entry không dừng các entry còn lại. dry_run cho phép xem trước cả batch mà không chạm vào file system, và kết quả thật còn kèm một dòng summary đếm số file đã chuyển.
server.registerTool("move_files", {
description: "Move files under organized/ without overwriting existing paths.",
inputSchema: z.object({
folder: z.string(),
moves: z.array(z.object({ name: z.string(), to_folder: z.string().optional() })).min(1),
dry_run: z.boolean().optional(),
}),
}, async ({ folder, moves, dry_run }) => {
const root = resolveFolder(folder);
const results = [];
for (const { name, to_folder } of moves) {
try {
const message = await moveOneFile(root, name, to_folder, dry_run);
results.push({ name, to_folder, ok: true, message });
} catch (error) {
results.push({ name, to_folder, ok: false, error: String(error) });
}
}
return { content: [{ type: "text", text: JSON.stringify({ results }) }] };
});
const transport = new StdioServerTransport();
await server.connect(transport);
Hai dòng cuối là chỗ server thực sự lên sóng: StdioServerTransport khai báo rằng server nói chuyện qua stdin/stdout của chính process của nó, nên host chỉ cần biết cách spawn process là kết nối được.
Hai điểm thiết kế đáng chú ý. Thứ nhất, một MCP server tổ chức file không cần và không nên có khả năng đọc nội dung bên trong file người dùng — tên, phần mở rộng và ngày sửa đổi là đủ để phân loại. Thứ hai, folder là một đường dẫn tuyệt đối bất kỳ, không phải tên một subfolder cố định trong thư mục project — nó sẽ tổ chức được bất kỳ thư mục thật nào có trên máy.
Vì folder mở như vậy, server đọc thêm biến môi trường ALLOWED_ROOTS: một danh sách đường dẫn tuyệt đối, phân cách bằng ; trên Windows và : trên macOS/Linux, và folder buộc phải nằm trong một trong các root đó. Nếu không set, server giữ hành vi mặc định của demo là nhận mọi đường dẫn tuyệt đối — chấp nhận được với dữ liệu mock, nhưng nên set khi trỏ vào dữ liệu thật.
Xây dựng custom Host/Agent với Gemini
client.ts ghép custom host/agent, Gemini và một MCP Client trong cùng process. Gemini chọn tool; MCP Client chỉ đảm nhiệm kết nối giao thức tới File MCP Server. Gemini API có free tier tại thời điểm kiểm chứng, với quota phụ thuộc vào model và tài khoản.
Tạo API key tại Google AI Studio, lưu vào GEMINI_API_KEY trong .env. MODEL là tùy chọn; code mặc định dùng gemini-2.5-flash.

Tạo API key Gemini tại Google AI Studio, đặt tên key và chọn project Gemini API.
Tiếp theo, custom host/agent tạo một MCP Client và kết nối với server qua stdio. Phần tính đường dẫn và giới hạn số tool call nằm trong source đầy đủ.
// client/client.ts
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
const transport = new StdioClientTransport({
command: process.execPath,
args: ["--import", "tsx", serverPath],
env: { ALLOWED_ROOTS: process.env.ALLOWED_ROOTS ?? "" },
});
const mcpClient = new Client({ name: "file-organizer-agent", version: "1.0.0" });
await mcpClient.connect(transport);
Môi trường kiểm chứng ngày 2026-08-27: Windows 11, Node.js 24.15.0, @modelcontextprotocol/client 2.0.0, @modelcontextprotocol/server 2.0.0, @google/genai 2.17.0, Zod 4.4.3, tsx 4.23.12 và TypeScript 7.0.2. MCP TypeScript SDK v2 tách client/server thành hai package; v1 dùng package gộp @modelcontextprotocol/sdk.
mcpToTool chuyển các MCP tool đã khám phá sang interface mà Gemini dùng. Đây là integration riêng của @google/genai và vẫn đang ở trạng thái experimental trong bản 2.17.0; SDK của provider khác có thể cần adapter hoặc vòng lặp tool-calling khác.
import { GoogleGenAI, mcpToTool } from "@google/genai";
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
try {
const response = await ai.models.generateContent({
model: MODEL,
contents: USER_REQUEST,
config: {
tools: [mcpToTool(mcpClient)],
automaticFunctionCalling: { maximumRemoteCalls: MAX_TOOL_CALLS },
},
});
console.log(response.text);
} finally {
await mcpClient.close();
}
Source đầy đủ của custom host/agent
Claude Desktop và Claude Code là các MCP Host có thể đăng ký server này; nhưng nền tảng khác cũng có thể hỗ trợ MCP. Khi đổi model provider, File MCP Server và tool contract có thể giữ nguyên, nhưng phần host/agent thường phải đổi SDK, API key, adapter và có thể cả vòng lặp tool-calling.
Muốn tổ chức một thư mục thật, chạy:
npm run agent -- "absolute_path"
Chạy demo: kết quả thật trên 500 file
Lần chạy hoàn tất với 2 tool call: một lần list_files và một lần move_files. Kết quả nằm dưới storage/inbox/organized/<extension>/<year>/<month>/<day>/. Các ảnh dưới đây là transcript và cấu trúc thư mục của lần chạy đó.

Terminal lúc bắt đầu chạy: lệnh npm run agent và lần gọi list_files đầu tiên.

Terminal lúc kết thúc: lần move_files cuối cùng, tổng 2 tool call và thông báo hoàn tất từ Gemini.

Thư mục organized sau khi chạy xong, được chia thành 7 phần mở rộng: xlsx, txt, png, pdf, jpg, docx, csv.

Một thư mục con cụ thể: organized/xlsx/2026/01/29 chứa đúng 1 file khớp với ngày sửa đổi.
Gọn hơn nhiều rồi phải không? Khi tìm kiếm thì chỉ cần nhớ đuôi file và ngày sửa đổi, thế là xong.
Đăng ký server này vào Claude Desktop hoặc Claude Code
client.ts là một host tự viết cho demo. Một cách dùng khác là đăng ký file server đã build vào Claude Desktop hoặc Claude Code; host sẽ quản lý kết nối, tools/list và vòng lặp tool-calling.
Với Claude Desktop, mở Settings → Developer → Edit Config và sửa claude_desktop_config.json. Bản cài thông thường trên Windows dùng %APPDATA%\\Claude\\claude_desktop_config.json. Bản Microsoft Store có thể dùng đường dẫn MSIX tại %LOCALAPPDATA%\\Packages\\<PackageFamilyName>\\LocalCache\\Roaming\\Claude\\claude_desktop_config.json.
Đường dẫn khai báo trong file này phải là đường dẫn tuyệt đối. Nếu chưa có file, hãy tạo mới:
{
"mcpServers": {
"file-organizer": {
"command": "node",
"args": ["absolute_path/dist/server/server.js"],
"env": { "ALLOWED_ROOTS": "C:\\Users\\you\\Downloads" }
}
}
}
ALLOWED_ROOTS phải chứa đúng thư mục sẽ tổ chức: ví dụ trên chỉ cho phép Downloads, nên nếu muốn thử trên dữ liệu mock của demo thì đổi sang đường dẫn tuyệt đối tới storage\inbox.
Claude Code, scope project dùng file .mcp.json ở project root. Đường dẫn tương đối trong .mcp.json được resolve theo working directory của process Claude Code chứ không theo vị trí file, nên để đường dẫn tuyệt đối, hoặc dùng biến ${CLAUDE_PROJECT_DIR:-.}/dist/server/server.js mà Claude Code set sẵn cho server subprocess.
Kết quả với Claude Desktop

Claude Desktop: bật connector file-organizer trong mục Connectors.
Kết quả với Claude Code

Claude Code: MCP server file-organizer hiện trạng thái Connected qua .mcp.json.
Tái hiện tại thư mục Downloads:

Thư mục Downloads thật trên máy, dùng để thử lại demo ngoài dữ liệu mock.
Claude tự tìm tool cần dùng từ MCP server:

Claude tự chọn gọi tool List files từ file-organizer và xin quyền trước khi đọc thư mục Downloads.
Điều MCP thực sự chuẩn hóa
Demo cho thấy ba phần được chuẩn hóa:
- Discovery: client lấy tool và schema khi chạy qua tools/list.
- Invocation: client gọi mọi tool qua tools/call và nhận kết quả theo cấu trúc MCP.
- Ranh giới triển khai: agent phụ thuộc vào
list_files,move_files, không phụ thuộc trực tiếp vàofs.readdirhayfs.rename.
Xét về mặt kiến trúc — backend phía sau list_files, move_files có thể đổi từ file system cục bộ sang Google Drive, OneDrive hay S3 mà cách agent hiểu và gọi tool không đổi:
MCP Server
│
┌───────────┼───────────┐
▼ ▼ ▼
Google Drive OneDrive S3
MCP tách việc agent hiểu hợp đồng tool khỏi cách server triển khai tool.
Khi nào MCP đáng hơn chi phí tích hợp
MCP phù hợp khi một khả năng cần được nhiều host, model hoặc đội phát triển dùng lại; danh sách tool cần được khám phá lúc chạy; hoặc server cần giữ ranh giới giữa quyền và business logic độc lập với agent. Ví dụ gồm tra cứu tồn kho từ ERP, lấy trạng thái vận đơn, truy vấn báo cáo nội bộ và tạo ticket.
Nếu chỉ có một ứng dụng gọi một API đơn giản, adapter trực tiếp thường ít code hơn. Nếu bài toán hoàn toàn xác định như "xếp file theo phần mở rộng", một script chạy thẳng vẫn rẻ và dễ dự đoán hơn việc dựng cả một agent để tự quyết định — kể cả khi agent đó đã dùng tool batch như move_files.
Hướng dẫn tự chạy lại demo này
Cách trực tiếp nhất để tự trải nghiệm demo là đăng ký server vào một MCP host có sẵn — Claude Desktop hoặc Claude Code — rồi gõ yêu cầu bằng ngôn ngữ tự nhiên trong khung chat, đúng như cách MCP được dùng trong thực tế. Các bước sau áp dụng trên Windows với Node.js LTS.
1. Clone repo rồi mở PowerShell tại thư mục vừa clone: git clone https://github.com/bwv-labs/mcp-file-organizer.git, sau đó cd mcp-file-organizer.
2. Chạy npm ci để cài đúng version trong lockfile.
3. Chạy npm run test, sau đó npm run generate-mock để tạo 500 file trong storage/inbox.
4. Chạy npm run build để tạo dist/server/server.js.
5. Đăng ký file JavaScript đã build theo mục trên, và đặt ALLOWED_ROOTS bằng đúng thư mục sẽ tổ chức — ở đây là đường dẫn tuyệt đối tới storage\inbox của repo.
6. Khởi động lại host hoặc mở phiên mới, rồi yêu cầu tổ chức đúng thư mục mock.
Ví dụ prompt:
Tổ chức tất cả file trong D:\path\to\mcp-file-organizer\storage\inbox
Kiểm tra permission đang áp dụng trên host trước khi cho phép tool ghi file. Sau khi chạy, xem kết quả tại storage/inbox/organized.
Kết luận
MCP giải quyết vấn đề nêu ở đầu bài: thay vì mỗi cặp ứng dụng–API cần một adapter riêng, một MCP Server có thể phục vụ nhiều host tương thích, đưa chi phí tích hợp gần từ n × m về n + m khi các thành phần thực sự được dùng lại. Một script cố định vẫn đơn giản hơn cho rule phân loại ổn định. Giá trị của demo nằm ở chỗ cùng một file MCP Server phục vụ được custom host/agent dùng Gemini và Claude Desktop mà không đổi contract của list_files và move_files.