Lần đầu tôi build một MCP server cho mấy chục nghìn bản ghi trong Postgres, tôi expose tất cả bằng Tool. Mỗi lần Claude cần dữ liệu, nó gọi query_db với SQL string, tôi parse, chạy, trả về JSON. Hoạt động được, nhưng có hai vấn đề. Một, client không biết “có cái gì để đọc” cho đến khi LLM đoán đúng câu query. Hai, mỗi lần gọi là một tool call, tốn token cho cả prompt schema lẫn JSON response.
Sau khi đọc lại spec MCP, tôi mới nhận ra mình đang dùng sai entity. Cái tôi cần là Resource, không phải Tool. Bài này nói về cách thiết kế Resource đúng: URI scheme, cách list, cách read, pagination với cursor, MIME type, và khi nào cần stream.
Resource khác Tool ở chỗ nào
Bài 1 (MCP overview) đã nói qua ba entity: Tool, Resource, Prompt. Ở đây tôi nói kỹ phần ranh giới giữa Tool và Resource, vì đó là chỗ đa số người mới làm sai.
Tool là function LLM chủ động gọi để thực hiện action. LLM nhìn schema tool, quyết định “tôi cần gọi cái này”, server thực thi và trả về kết quả. Tool thường có side effect: tạo issue, gửi mail, chạy query mutation.
Resource là data source application expose ra cho client. Application (Claude Desktop, Claude Code, custom host) quyết định khi nào load resource vào context. LLM không tự chọn gọi resource. Resource thường read-only, idempotent, và có URI ổn định.
Ví dụ cụ thể từ một MCP server cho project codebase:
| Việc | Nên là Tool hay Resource |
|---|---|
Đọc nội dung file README.md | Resource (file:///project/README.md) |
| Search keyword trong codebase | Tool (grep_code, có input) |
| List branch hiện tại | Resource (git://branches) hoặc Tool, tuỳ |
| Tạo branch mới | Tool (create_branch, side effect) |
| DB schema dump | Resource (postgres://schema) |
Chạy SELECT * FROM users WHERE id=... | Tool (cần input cụ thể) |
Heuristic của tôi: nếu user có thể click chọn “thêm cái này vào context” trong UI, đó là Resource. Nếu LLM phải tự đoán parameter rồi gọi, đó là Tool.
Spec MCP 2025-06-18 nói rõ: “Resources in MCP are designed to be application-driven, with host applications determining how to incorporate context based on their needs.” Nghĩa là quyền quyết định khi nào load nằm ở host app, không ở LLM. Đây là khác biệt thiết kế quan trọng so với function calling cũ.
Một hệ quả thực tế: nếu bạn expose Resource, host app sẽ render chúng trong picker (Claude Desktop có resource picker dạng tree view ở thanh attach). User chọn rồi nội dung mới được nhồi vào context. Còn Tool thì LLM tự gọi trong vòng lặp reasoning, có thể nhiều lần một turn, không user nào kiểm duyệt giữa các lần. Cùng một data, expose qua Tool đồng nghĩa giao quyền control cho model; expose qua Resource giữ quyền cho user. Đó là quyết định product, không chỉ kỹ thuật.
URI scheme: dùng cái gì cho cái gì
Mỗi resource được định danh bằng một URI duy nhất, theo RFC3986. Spec liệt kê vài scheme chuẩn và cho phép custom:
file://: cho resource hành xử như filesystem. Không nhất thiết phải map sang filesystem vật lý. Ví dụ một MCP server cho Google Drive vẫn có thể dùng file:///Documents/note.md nếu muốn mô phỏng filesystem semantic. Cần ID directory thì spec cho phép xài XDG MIME type như inode/directory.
https://: cho resource lấy từ web mà client tự fetch được. Spec khuyên CHỈ dùng khi client có thể tải trực tiếp, không phải đi qua MCP server. Nếu server cần proxy nội dung, dùng custom scheme thay vì https.
git://: cho tích hợp git version control.
Custom scheme: hoàn toàn được phép, miễn tuân thủ RFC3986. Tôi thường thấy postgres://, slack://, notion://, screen://, audio://. Một ví dụ:
postgres://prod-replica/public.users/schema
slack://workspace/C012345/messages?date=2026-05-20
screen://display1/region?x=0&y=0&w=1920&h=1080
Quy tắc đặt URI scheme:
- Mỗi resource phải có URI ổn định trong session. Cùng một resource đọc 2 lần phải trả về cùng giá trị (hoặc ít nhất cùng identity).
- URI nên describe “cái gì” chứ không phải “lấy ra sao”.
postgres://schematốt hơnmcp://server/method/dump. - Path segment nên có ý nghĩa với người đọc khi debug log. URL encoded base64 trong path thì khó debug.
- Không nhét secret vào URI. URI thường được log, hiển thị trong UI, lưu trong cache. Nếu cần auth, dùng OAuth bearer token theo bài 4, không phải
postgres://user:password@host/.... - Nếu server cần phân biệt nhiều instance backend cùng loại, đặt instance name vào path đầu chứ đừng dồn vào query string.
s3://prod-bucket/...rõ hơns3:///?bucket=prod-bucket&key=....
resources/list và resources/read
Hai method JSON-RPC chính cho Resource là resources/list (discovery) và resources/read (fetch nội dung).
resources/list request:
{
"jsonrpc": "2.0",
"id": 1,
"method": "resources/list",
"params": {
"cursor": "optional-cursor-value"
}
}
Response trả về array các resource metadata kèm nextCursor nếu còn trang sau:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"resources": [
{
"uri": "file:///project/src/main.rs",
"name": "main.rs",
"title": "Rust Software Application Main File",
"description": "Primary application entry point",
"mimeType": "text/x-rust"
}
],
"nextCursor": "next-page-cursor"
}
}
Field bắt buộc trong resource metadata: uri, name. Field optional nhưng nên có: title (human-readable, hiển thị trong UI), description (giúp user chọn), mimeType (giúp client render đúng), size (giúp client cảnh báo nếu file lớn).
resources/read request truyền URI cụ thể:
{
"jsonrpc": "2.0",
"id": 2,
"method": "resources/read",
"params": {
"uri": "file:///project/src/main.rs"
}
}
Response trả về một array contents, trong đó mỗi phần tử là một content block với text hoặc blob:
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"contents": [
{
"uri": "file:///project/src/main.rs",
"mimeType": "text/x-rust",
"text": "fn main() {\n println!(\"Hello world!\");\n}"
}
]
}
}
Lưu ý hai chi tiết hay bỏ sót:
contentslà array. Một URI có thể trả nhiều block (ví dụ file Markdown kèm image attachment). Đa số implementation trả 1 phần tử.- Content có thể là
text(UTF-8 string) hoặcblob(base64-encoded). Không trả cả hai cùng lúc.
Pagination với cursor (opaque)
Khi server có nhiều resource (vài nghìn file, vài chục table), trả hết trong một response sẽ chết bandwidth và token. MCP dùng cursor-based pagination, không phải offset-based.
Cursor trong MCP là opaque string token. Spec nói rõ:
- Client MUST treat cursor as opaque token. Không được parse, không được modify, không được persist qua session.
- Server SHOULD provide stable cursor và handle invalid cursor gracefully.
- Page size do server quyết định. Client MUST NOT giả định fixed page size.
Flow điển hình:
Client -> Server: { method: "resources/list" }
Server -> Client: { resources: [...50 items], nextCursor: "eyJwYWdlIjogMn0=" }
Client -> Server: { method: "resources/list", params: { cursor: "eyJwYWdlIjogMn0=" } }
Server -> Client: { resources: [...50 items], nextCursor: "eyJwYWdlIjogMn0=" }
... lặp cho đến khi response không còn nextCursor ...
Khi response không có nextCursor, client biết đã hết. Invalid cursor (server không nhận ra) trả lỗi JSON-RPC code -32602 (Invalid params).
Tại sao opaque, không phải offset? Vì server có thể implement cursor là base64 của database offset, hoặc keyset pagination (last seen ID), hoặc thậm chí continuation token của một stream backend. Client không nên biết. Một ví dụ payload cursor server-side (server tự free để chọn):
// Server-side, không expose ra client
const cursor = Buffer.from(JSON.stringify({
lastId: lastResource.id,
schemaVersion: 2,
}), 'utf-8').toString('base64')
Có hai pattern nên tránh:
- Đừng dùng offset-based pagination giả opaque: nếu cursor luôn là
"page=N", client tinh ý sẽ parse và lạm dụng. Bad surprise khi schema cursor đổi. - Đừng persist cursor qua restart server: nếu data thay đổi giữa hai request, cursor stale có thể trả nhầm trang hoặc skip data. Stable trong session là đủ.
resources/list, resources/templates/list, prompts/list, tools/list đều support pagination theo cùng cơ chế cursor này.
Page size bao nhiêu? Spec không quy định. Quy tắc tôi đang dùng: 50-100 cho list metadata nhỏ (mỗi item vài trăm byte), 10-20 khi mỗi item kèm description dài, và bỏ qua pagination hoàn toàn nếu tổng list dưới vài trăm item.
Nhớ rằng nextCursor không bắt buộc nếu list trả hết trong một response. Server hoàn toàn được phép trả 500 item không kèm cursor nếu thấy hợp lý cho data set hiện tại. Pagination là cơ chế optional, không phải mandatory; đừng implement chỉ vì spec nhắc tới.
MIME type: hint cho client render
MIME type trong resource metadata không bắt buộc, nhưng nên có. Nó giúp client (Claude Desktop, IDE plugin) biết phải render thế nào trước khi user click.
Vài MIME type tôi hay dùng:
| MIME | Khi nào dùng |
|---|---|
text/plain | Plain text fallback |
text/markdown | Markdown content |
text/x-rust, text/x-python | Source code, dùng MIME chuẩn của ngôn ngữ |
application/json | JSON config, schema dump, API response |
application/xml | XML payload |
text/csv | CSV table dump |
image/png, image/jpeg | Binary image, dùng kèm blob |
application/pdf | PDF binary |
application/octet-stream | Binary chưa biết loại, fallback |
inode/directory | Resource là directory (XDG MIME) |
Quy tắc:
- Text content thì set MIME
text/*hoặcapplication/jsonvà truyền qua fieldtext. - Binary content set MIME nhị phân và truyền qua field
blob(base64). - Nếu không chắc, để
application/octet-streamcho binary,text/plaincho text. Đừng để trống nếu có thể tránh.
Mismatch giữa MIME khai báo và nội dung thật là một nguồn bug khó debug. Ví dụ server trả mimeType: "application/json" nhưng text không phải JSON hợp lệ; client cố parse, ném lỗi, không hiển thị. Hoặc server trả mimeType: "image/png" nhưng quên base64-encode blob. Khi viết unit test cho server, nên có một test riêng kiểm mimeType khớp với format thực của content.
Spec MCP 2025-06-18 cũng hỗ trợ annotation trong resource metadata, gồm audience (user / assistant), priority (0.0 đến 1.0), và lastModified (ISO 8601). Client dùng những hint này để filter và sort. Ví dụ một README quan trọng:
{
"uri": "file:///project/README.md",
"name": "README.md",
"title": "Project Documentation",
"mimeType": "text/markdown",
"annotations": {
"audience": ["user"],
"priority": 0.8,
"lastModified": "2025-01-12T15:00:58Z"
}
}
Large payload: phân mảnh, sample, hoặc dùng Tool
Spec MCP chưa định nghĩa chunked transfer cho resources/read. Một response là một response. Vậy file 500MB phải làm sao?
Có ba pattern thực dụng:
Pattern 1, slice qua URI param. Expose nhiều resource nhỏ thay vì một resource khổng lồ. Ví dụ một log file 2GB:
log://prod/2026-05-21/00-04
log://prod/2026-05-21/04-08
log://prod/2026-05-21/08-12
Client list ra, chọn cái mình cần, read từng phần.
Pattern 2, ResourceTemplate với URI parameter. Spec cho phép expose template URI parameterized (theo RFC6570). Client có thể đọc resource dynamic bằng cách bind parameter:
{
"resourceTemplates": [
{
"uriTemplate": "log://prod/{date}/{hourRange}",
"name": "Production logs",
"description": "Hour range like 00-04, 04-08",
"mimeType": "text/plain"
}
]
}
Argument trong template có thể auto-complete qua completion API.
Pattern 3, dùng Tool thay vì Resource cho data thực sự lớn. Nếu cần streaming, filtering, hoặc pagination phức tạp trong nội dung (không phải trong list), một tool query_logs với input schema chi tiết phù hợp hơn. Tool có thể trả về paginated result với cursor riêng trong response.
Bản thân MCP chưa có “streaming read” cho resource trong spec 2025-06-18. Nếu cần real-time data feed (log tail, metrics stream), giải pháp là resources/subscribe để nhận notifications/resources/updated, rồi re-read khi có thay đổi. Đó không phải streaming theo nghĩa SSE, nhưng đủ cho hầu hết use case.
Có một câu hỏi mở tôi hay nhận: tại sao không dùng https:// cho file lớn rồi để client tự download? Có thể, nhưng spec khuyên https:// chỉ dùng khi client load thẳng từ web không cần MCP server xử lý gì. Trong trường hợp resource thực sự thuộc về server (file private trong S3, document trong Notion workspace), trả https:// đồng nghĩa với việc lộ presigned URL ra client, kèm theo nguy cơ URL leak qua log. Trừ khi presigned URL có TTL ngắn và scope chặt, tốt hơn dùng custom scheme và đi qua server.
Code example: register resource với TypeScript SDK
Tôi dùng @modelcontextprotocol/server để minh hoạ hai pattern: static resource và resource template. Code này lấy ý từ ví dụ chính thức của TS SDK.
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/server'
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'
const server = new McpServer(
{ name: 'codebase-server', version: '1.0.0' },
{
capabilities: {
resources: {
subscribe: false,
listChanged: true,
},
},
},
)
// Static resource: URI cố định
server.registerResource(
'config',
'config://app',
{
title: 'Application Config',
description: 'Application configuration data',
mimeType: 'text/plain',
},
async (uri) => ({
contents: [
{
uri: uri.href,
text: 'App configuration here',
},
],
}),
)
// Template resource: URI parameterized
server.registerResource(
'user-profile',
new ResourceTemplate('user://{userId}/profile', {
list: async () => ({
resources: [
{ uri: 'user://123/profile', name: 'Alice' },
{ uri: 'user://456/profile', name: 'Bob' },
],
}),
}),
{
title: 'User Profile',
description: 'User profile data',
mimeType: 'application/json',
},
async (uri, { userId }) => ({
contents: [
{
uri: uri.href,
mimeType: 'application/json',
text: JSON.stringify({ userId, name: 'Example User' }),
},
],
}),
)
const transport = new StdioServerTransport()
await server.connect(transport)
Vài điểm đáng chú ý trong code:
capabilities.resources.listChanged: truebáo cho client biết server sẽ gửinotifications/resources/list_changedkhi danh sách thay đổi. Nếu không hỗ trợ, đểfalsehoặc bỏ field.registerResourcevới URI string là static. Truyềnnew ResourceTemplate(...)để parameterize.ResourceTemplate.listcung cấp callback liệt kê các URI cụ thể đã expand từ template. Đây là chỗ bạn implement pagination nếu cần (returnnextCursor).- Read callback nhận
uri(đã parse) và object parameter cho template. Trả vềcontentsarray.
Pagination trong list callback có thể trông như sau:
new ResourceTemplate('log://prod/{date}/{hour}', {
list: async (params, { cursor }) => {
const PAGE = 50
const offset = cursor ? parseCursor(cursor) : 0
const rows = await db.logs.findMany({ skip: offset, take: PAGE + 1 })
const hasMore = rows.length > PAGE
const page = rows.slice(0, PAGE)
return {
resources: page.map((r) => ({
uri: `log://prod/${r.date}/${r.hour}`,
name: `${r.date} ${r.hour}h`,
mimeType: 'text/plain',
})),
nextCursor: hasMore ? encodeCursor(offset + PAGE) : undefined,
}
},
})
encodeCursor và parseCursor là helper riêng. Trả undefined (hoặc không trả nextCursor) báo client là đã hết.
Error handling
Spec yêu cầu server return JSON-RPC error chuẩn cho các trường hợp thường gặp:
- Resource not found:
-32002 - Internal errors:
-32603 - Invalid params (bao gồm invalid cursor):
-32602
Ví dụ response lỗi:
{
"jsonrpc": "2.0",
"id": 5,
"error": {
"code": -32002,
"message": "Resource not found",
"data": {
"uri": "file:///nonexistent.txt"
}
}
}
Đừng nuốt lỗi rồi trả contents rỗng. Client cần biết “không có” khác với “rỗng” để báo lên UI hoặc retry.
Subscribe và list-changed
Hai feature optional liên quan đến notification:
subscribe: client gọiresources/subscribevới URI cụ thể, server emitnotifications/resources/updatedkhi resource thay đổi. Client tự re-read sau notification.listChanged: server emitnotifications/resources/list_changedkhi danh sách resource thay đổi (file mới, bảng mới). Client re-list.
Cả hai đều optional và độc lập. Server khai báo trong capability ban đầu. Use case điển hình: dev server expose source files, file change trigger list_changed; database server expose schema, schema migration trigger update notification cho resource schema://main.
Đừng implement subscribe nếu data đứng yên. Mỗi subscription là một state server phải maintain. Static config? Bỏ qua.
Một gotcha tôi gặp: nếu server crash hoặc restart, subscription bên client coi như mất. Client phải tự re-subscribe sau khi reconnect. Server không có cơ chế chuẩn để “replay” missed updates. Nếu use case cần guarantee không miss update (audit log, financial event), MCP subscription không phải lựa chọn đúng. Dùng broker event riêng (Kafka, NATS, Redis Streams) và expose snapshot qua Resource là pattern thực dụng hơn.
Security checklist
Spec nhắc bốn điểm bảo mật, áp dụng tốt cho mọi production server:
- Server MUST validate mọi resource URI trước khi serve. Không trust URI từ client.
- Access control nên implement cho resource nhạy cảm. Cùng một MCP server có thể serve nhiều user, cần kiểm tra quyền per-request.
- Binary data MUST encode đúng base64. Đừng truyền raw byte qua JSON.
- Permission check trước operation, không phải sau.
Bonus từ thực tế: log URI request kèm session ID. Khi user báo “tôi không xem được file X”, log giúp dễ debug whitelist filter.
Resource là entity bị design ẩu nhất
Đây là quan điểm tôi đứng vững: trong ba entity của MCP, Resource là cái bị design ẩu nhất. Tool có annotation, có schema strict, có spec rõ. Prompt đơn giản, ít chỗ sai. Resource thì spec mở rộng quá, ai cũng nhồi gì vào URI cũng được, ai cũng tự nghĩ ra cursor format, ai cũng mark subscribe: true cho data đứng yên.
Sau khi build vài server, tôi rút lại còn ba câu hỏi trước khi expose data qua Resource. Data có URI ổn định không (nếu không, dùng Tool). User có muốn click chọn manual không (nếu không, dùng Tool). Có cần stream real-time không (nếu có, MCP subscribe không đủ, dùng broker event riêng). Trả lời được ba câu, design Resource đúng từ đầu.
Phần tiếp của series chuyển sang Tool design, nơi LLM gọi trực tiếp không qua user. Kỹ thuật phòng thủ ở đó khác Resource hoàn toàn, vì attacker không còn là “data lỗi” mà là “model gọi sai cách”.