Tưởng tượng bạn mở Claude Desktop, yêu cầu "Research về xu hướng AI trong healthcare 3 tháng qua, viết thành báo cáo 5 trang.".
- Giải thích sự khác biệt giữa log notification và progress notification (và khi nào dùng cái nào).
- Thêm notifications vào tool Python chỉ với 2-3 dòng code dùng Context object.
- Viết callback phía client để xử lý notification (CLI, web, desktop).
- Thiết kế progress cadence hợp lý (không quá dày, không quá thưa).
- Nhận biết khi nào notification không đến user (transport flags từ bài 10.5).
Mở đầu: Im lặng là kẻ thù của UX
Claude gọi tool research của bạn. Tool bạn làm:
- Fetch 20 URL từ web.
- Parse HTML, clean content.
- Gửi 20 chunk vào LLM để extract key points.
- Synthesize thành báo cáo.
- Output file.
Tổng thời gian: 3-4 phút.
Từ phía user trong 3 phút đó, họ thấy gì?
- Không có notification
- một spinner đứng yên, không biết tool đã hỏng chưa, không biết đang ở bước nào. 40% user sẽ cancel giữa chừng vì "tưởng treo".
- Có notification
- progress bar chạy dần
20% → 45% → 70% → 95%, kèm log text thay đổi: "Fetching URLs...", "Parsing content...", "Extracting insights...", "Writing report...". User relax, không cancel.
Sự khác biệt này không tốn nhiều code. 2-3 dòng trong tool của bạn, xong. Đây là low-hanging fruit UX cao nhất bạn có thể làm cho MCP server.
Hai loại notifications — Log vs Progress
MCP phân 2 loại notification cho UX này:
┌───────────────────────────────────────────────────────────┐
│ │
│ LOG (notifications/message) │
│ ──────────────────────── │
│ Mục đích: gửi text cho user đọc │
│ Cấu trúc: { level, data, logger? } │
│ Dùng khi: milestone, event, status update │
│ UI hiển thị: thường là dòng log scroll / activity feed │
│ │
│ Ví dụ: │
│ ctx.info("Fetched 20 URLs") │
│ ctx.warning("Rate limit reached, waiting 5s") │
│ ctx.error("Failed to parse 1 URL, skipping") │
│ │
│ │
│ PROGRESS (notifications/progress) │
│ ─────────────────────────────── │
│ Mục đích: báo số % hoàn thành │
│ Cấu trúc: { progress, total?, message? } │
│ Dùng khi: có thể ước tính bao nhiêu phần đã xong │
│ UI hiển thị: progress bar hoặc percentage │
│ │
│ Ví dụ: │
│ ctx.report_progress(20, 100, "Fetching...") │
│ ctx.report_progress(70, 100, "Writing report...") │
│ │
└───────────────────────────────────────────────────────────┘
Khi nào dùng log?
- Milestone không đo được bằng %: "Kết nối DB", "Got API response", "Retry lần 2".
- Warning/error cần kể rõ: "Skip file vì format không support".
- Context bổ sung: "Found 52 tickets matching criteria".
Khi nào dùng progress?
- Biết trước tổng: "sẽ fetch 20 URL, đã fetch 5" →
progress=5, total=20. - Không biết total nhưng muốn báo "đang chạy":
progress=tăng dần, total=None(UI sẽ hiện spinner indeterminate). - Các giai đoạn cố định: 25% → 50% → 75% → 100%.
Quy tắc: dùng cả hai. Log cho detail, progress cho visual. Chúng bổ sung nhau, không thay thế nhau.
Code phía server — 3 phút thêm notifications
Cách đơn giản nhất: nhận Context argument trong tool function, gọi các method.
Trước khi có notifications
from mcp.server.fastmcp import FastMCP
mcp = FastMCP(name="research")
@mcp.tool()
async def research(topic: str) -> str:
sources = await fetch_urls(topic)
content = await parse_all(sources)
report = await synthesize(content)
return report
Tool này chạy 3 phút trong im lặng. User nhìn spinner.
Sau khi thêm notifications
from mcp.server.fastmcp import FastMCP
from mcp.server.fastmcp import Context
from pydantic import Field
mcp = FastMCP(name="research")
@mcp.tool(
name="research",
description="Research a given topic and produce a report"
)
async def research(
topic: str = Field(description="Topic to research"),
*,
ctx: Context,
) -> str:
await ctx.info(f"Starting research on: {topic}")
await ctx.report_progress(10, 100, "Fetching sources...")
sources = await fetch_urls(topic)
await ctx.info(f"Fetched {len(sources)} sources")
await ctx.report_progress(40, 100, "Parsing content...")
content = await parse_all(sources)
await ctx.report_progress(70, 100, "Writing report...")
report = await synthesize(content)
await ctx.report_progress(100, 100, "Done")
await ctx.info("Report ready")
return report
Điểm đáng chú ý:
ctx: Contextlà keyword-only argument (sau*,). Đây là convention của SDK.ctx.info()gửi log level info. Cònctx.warning(),ctx.error(),ctx.debug().ctx.report_progress(current, total, message)—messageoptional, giúp UI hiện text kèm progress bar.ctx.report_progress(progress)không total → UI hiện indeterminate spinner.- Không cần handle gì phía server — SDK tự wrap thành notification JSON gửi về client.
Code phía client — Setup 2 callback
Server emit notification, client quyết định hiển thị. Client có thể ignore hoàn toàn, hoặc hiển thị CLI, web, mobile...
Ví dụ CLI client đơn giản:
from mcp.client.stdio import stdio_client, StdioServerParameters
from mcp import ClientSession
from mcp.types import LoggingMessageNotificationParams
async def logging_callback(params: LoggingMessageNotificationParams):
level = params.level.upper()
print(f"[{level}] {params.data}")
async def progress_callback(
progress: float,
total: float | None,
message: str | None,
):
if total:
percentage = (progress / total) * 100
bar = "█" * int(percentage / 5) + "─" * (20 - int(percentage / 5))
print(f"\r[{bar}] {percentage:.0f}% {message or ''}", end="", flush=True)
else:
print(f"Progress: {progress} {message or ''}")
async def run():
server_params = StdioServerParameters(command="uv", args=["run", "server.py"])
async with stdio_client(server_params) as (read, write):
async with ClientSession(
read,
write,
logging_callback=logging_callback, # đăng ký 1 lần cho session
) as session:
await session.initialize()
result = await session.call_tool(
name="research",
arguments={"topic": "AI in healthcare"},
progress_callback=progress_callback, # đăng ký per tool call
)
print(f"\nResult: {result}")
import asyncio
asyncio.run(run())
Chú ý 2 mức độ đăng ký:
logging_callbackgắn với session. Mọi log notification trong session sẽ đi qua callback này.progress_callbackgắn với tool call cụ thể. Mỗi tool call đăng ký riêng. Cho phép UI khác cho các tool khác.
Progress cadence — Nghệ thuật của tần suất
Gửi progress bao nhiêu lần là vừa? Đây là một chủ đề nhỏ nhưng quan trọng cho UX.
Quá thưa
await ctx.report_progress(0, 100, "Starting")
await ctx.report_progress(100, 100, "Done")
User thấy progress đứng ở 0% trong 3 phút → tưởng hỏng. Đừng làm vậy.
Quá dày
for i, url in enumerate(urls): # 1000 urls
fetch(url)
await ctx.report_progress(i, 1000)
1000 notification trong vài giây → spam UI, có khi còn chậm hơn vì transport overhead. Nếu dùng HTTP, có thể stuck network.
Sweet spot: 10-30 update cho 1 tool call
for i, url in enumerate(urls):
fetch(url)
if i % 50 == 0:
await ctx.report_progress(i, 1000, f"Fetched {i}/1000")
Hoặc dùng thời gian:
last_update = time.time()
for i, url in enumerate(urls):
fetch(url)
if time.time() - last_update > 1.0:
await ctx.report_progress(i, 1000)
last_update = time.time()
Quy tắc vàng: 1-2 update mỗi giây đủ cho UI mượt mà.
Log level — Dùng đúng cho đúng context
MCP hỗ trợ 4 log level (giống Python logging):
| Level | Dùng khi | Ví dụ |
|---|---|---|
| debug | Chi tiết sâu cho dev | "Cache hit for URL X" |
| info | Progress, milestone | "Fetched 20 sources" |
| warning | Có issue nhưng vẫn chạy được | "Rate limit, retrying in 5s" |
| error | Lỗi (tool vẫn có thể trả kết quả partial) | "Failed to parse URL, skipped" |
Client có thể filter theo level. Mặc định info trở lên hiện cho user, debug chỉ hiện trong dev mode.
Mẹo: cấu trúc message dạng structured
Thay vì:
await ctx.info("Fetched 20 URLs")
Dùng structured data (một số SDK hỗ trợ):
await ctx.info({
"event": "fetch_complete",
"count": 20,
"duration_ms": 1250
})
Client có thể parse và render đẹp hơn (table, chart) thay vì plain text.
Khi notification KHÔNG đến user — Kiểm tra transport
Notifications phụ thuộc kênh truyền dẫn. Quay lại bài 10.5:
| Config | Log (trong tool) | Progress |
|---|---|---|
| stdio | ||
| HTTP, default | (qua tool SSE) | (qua primary SSE) |
| HTTP, json_response=True | (response không stream) | (vẫn qua primary SSE nếu stateful) |
| HTTP, stateless_http=True |
Nếu user báo "không thấy progress":
- Check config flag.
- Check client có đăng ký callback không.
- Check proxy có buffer SSE không (mẹo bài 10.4).
Đây là lý do bài 10.5 PHẢI đọc trước bài này — bạn cần biết transport mới debug được notifications fail.
Presentation patterns theo loại app
CLI app
async def progress_cb(progress, total, message):
print(f"[{progress:.0f}/{total:.0f}] {message}")
async def log_cb(params):
print(f"[{params.level}] {params.data}")
Upgrade: dùng rich library cho progress bar đẹp, spinners, color log.
Web app (React/Vue)
Backend phải bridge MCP notifications sang client browser. Có 3 cách:
- WebSocket — backend giữ MCP session, forward notification qua WS cho browser.
- SSE (không liên quan MCP SSE) — browser mở SSE riêng tới backend.
- Polling — browser poll
/status/:job_idmỗi giây. Đơn giản nhất nhưng latency cao.
Ví dụ WebSocket pattern:
// Browser
const ws = new WebSocket("wss://app.com/mcp-notifications");
ws.onmessage = (e) => {
const { type, data } = JSON.parse(e.data);
if (type === "progress") updateProgressBar(data);
if (type === "log") appendToLog(data);
};
Desktop app (Electron, Tauri)
Claude Desktop là một ví dụ. Có thể update UI component trực tiếp:
session.onProgress((p, t, msg) => {
store.setProgress({ current: p, total: t, message: msg });
});
Chat bot (Slack, Discord)
Progress notification thường không fit chat UI (message nhiều spam). Thay vào đó:
- Log → edit tin nhắn ban đầu, không post tin mới.
- Progress → chỉ post khi chuyển giai đoạn (25%, 50%, 75%, 100%).
- Hoặc ignore hoàn toàn progress, chỉ dùng log ở level info.
Ví dụ theo ngành
Developer tool — build_project trong IDE
Pain: Build project lớn mất 2-5 phút. Developer nhìn terminal muốn biết đang compile file nào.
Giải pháp:
- Progress:
report_progress(files_compiled, total_files, current_file_name). - Log:
ctx.info("Compiling src/lib/foo.ts")mỗi file. - Error:
ctx.error("Type error in bar.ts line 42")nếu có. - IDE hiển thị bottom bar: progress bar + current file.
- Kết quả: Developer biết build đang ở file nào, không cần tail log terminal riêng.
Research tool — deep_research(topic)
Pain: 3-5 phút chạy, user không kiên nhẫn.
Giải pháp:
- 4 giai đoạn rõ: 25% Searching → 50% Reading → 75% Synthesizing → 100% Writing.
- Mỗi giai đoạn thêm 3-5 log events chi tiết.
- User có transparency, retry rate giảm 35% → 4%.
- Kết quả: User satisfaction rating tăng 20% trong 1 quarter.
Data pipeline — run_etl_job
Pain: ETL chạy hàng giờ, user không biết đang ở bước nào, có hỏng không.
Giải pháp:
- Dùng log level
debugcho chi tiết (cache hit, SQL query). infocho milestone (đã extract 10k rows, đã transform xong).warningcho retry.errorcho row bị skip.- Progress theo row: 0%, 25%, 50%, 75%, 100%.
- Kết quả: Support ticket "ETL treo?" giảm 80%. User tự debug được.
Content generation — repurpose_article
Pain: Tạo 5 format (blog, LinkedIn, Twitter, email, slides) cho 1 bài → 2-3 phút.
Giải pháp:
- 5 milestone (mỗi format 1 step): 20% → 40% → 60% → 80% → 100%.
- Log: "Generated LinkedIn version (1200 words)".
- User thấy từng format xong dần → có thể xem trước trong khi các format khác vẫn đang tạo.
- Kết quả: Hit rate tăng vì user cảm thấy chủ động.
Healthcare — analyze_patient_record
Pain: Tool tổng hợp medical record của 1 bệnh nhân (lab results + imaging notes + prescription history) tốn 20-30 giây. Clinician tưởng tool treo, hủy rồi retry → tăng load DB không cần thiết.
Giải pháp:
- Progress với 4 giai đoạn: "Fetching lab results (10 records)", "Parsing imaging reports (3 files)", "Cross-referencing prescriptions", "Generating summary".
- Log
warningkhi có abnormal lab value phát hiện trong quá trình. - Kết quả: Retry rate giảm rõ rệt; clinician yên tâm chờ kết quả thay vì nghi ngờ.
Legal — review_contract_diff
Pain: Tool so sánh 1 contract mới với 5 template chuẩn của firm, flag mọi điều khoản lệch. Contract 50 trang → 1-2 phút xử lý. Legal counsel hay mở nhiều task song song, mất dấu task nào của contract nào.
Giải pháp:
- Progress với message prefix tên contract:
"[ContractXYZ] Parsing clauses 20/80". - Log
infocho từng section đã review:"[ContractXYZ] Indemnity clause matches template v3". - Kết quả: Legal counsel theo dõi được đồng thời 5 contract review mà không lẫn lộn.
Anti-patterns
Log quá nhiều — spam user
- Hiện tượng
- Mỗi row trong DB = 1 log → 10,000 log cho 1 query.
- Cách đúng
- Batch log mỗi 100/1000 rows. Chỉ log milestone, không log trivia.
Progress không linear — nhảy cóc
- Hiện tượng
- Progress 5% → 80% → 85% → 90% → 100%. User confused.
- Cách đúng
- Ước tính workload cho từng giai đoạn, phân bổ % tương ứng. Nếu giai đoạn A chiếm 60% thời gian, dành 60% progress cho nó.
Dùng log để thông báo error fatal
- Hiện tượng
- Tool fail →
ctx.error("tool failed")rồi return. User không biết vì sao tool "trả về rỗng". - Cách đúng
- Fail tool bằng raise exception (SDK tự wrap thành error response).
ctx.error()chỉ dùng cho error không fatal (vd skip 1 record trong batch).
Block tool bằng await ctx.info(...) cho log dày
- Hiện tượng
- Mỗi log awaitable → bottle neck nếu gọi hàng trăm lần.
- Cách đúng
- SDK có buffer. Nếu cần high throughput, batch hoặc dùng
fire_and_forgetpattern (không await). Kiểm tra SDK version support gì.
Assume client sẽ render đẹp
- Hiện tượng
- Server gửi log với emoji, markdown, ANSI color. Client CLI cũ không render → hiện garbled.
- Cách đúng
- Giữ log đơn giản. Plain text UTF-8. Format ở client side nếu cần.
Thiếu fallback khi notification không đến
- Hiện tượng
- Code assume notifications work → không handle case transport bị stateless.
- Cách đúng
- Capability check khi init. Nếu client không support log, tool vẫn phải chạy và trả result.
Mẹo nâng cao
Mẹo 1: Dùng ctx.log wrapper cho consistency
Tạo wrapper ngắn gọn:
class LogHelper:
def __init__(self, ctx: Context, step_total: int):
self.ctx = ctx
self.step = 0
self.total = step_total
async def step_done(self, message: str):
self.step += 1
await self.ctx.report_progress(self.step, self.total, message)
await self.ctx.info(f"[{self.step}/{self.total}] {message}")
log = LogHelper(ctx, step_total=5)
await log.step_done("Fetching sources")
await log.step_done("Parsing HTML")
Giảm repetition, consistency cao.
Mẹo 2: Capture log vào history cho debug
Client nên lưu notifications vào ring buffer (last 100 entries). User có thể expand "show log" khi tool fail để xem lại.
Mẹo 3: Dùng progress total=None cho giai đoạn không biết trước
await ctx.report_progress(progress=0, total=None, message="Searching...")
UI hiển thị spinner indeterminate + text. Tốt hơn là không gửi gì.
Mẹo 4: Nested progress cho long-running tool
Một tool call nhiều sub-task. Progress 0-100% cho overall, log cho chi tiết từng sub-task.
await ctx.report_progress(20, 100, "Phase 1 of 5")
for i, sub in enumerate(subs):
await ctx.info(f"Phase 1 sub-task {i+1}: {sub.name}")
await process(sub)
await ctx.report_progress(40, 100, "Phase 2 of 5")
Mẹo 5: Correlate log với trace ID
Nếu backend có distributed tracing, include trace ID trong log:
await ctx.info(f"[trace={trace_id}] Querying DB")
Dev có thể join log MCP với trace backend để debug end-to-end.
Áp dụng ngay
Bài tập 1: Thêm notifications vào tool chậm (15 phút)
Bước 1: Tạo server với tool giả lập chạy chậm:
import asyncio
from mcp.server.fastmcp import FastMCP, Context
from pydantic import Field
mcp = FastMCP(name="demo")
@mcp.tool()
async def slow_process(
items: int = Field(description="Number of items to process"),
*,
ctx: Context,
) -> str:
# TODO: thêm notifications
for i in range(items):
await asyncio.sleep(0.2)
return f"Processed {items} items"
if __name__ == "__main__":
mcp.run()
- Bước 2
- Thêm log + progress để user thấy tool đang chạy.
- Bước 3
- Test bằng MCP Inspector gọi tool với
items=50. - Bước 4
- Ghi lại:
- Số notification bạn emit: ___________
- Cadence (khoảng cách giữa 2 progress): ___________
- Có log nào là
warningkhông? ___________
Bài tập 2 (optional): Viết client CLI với progress bar
Dùng library rich hoặc tqdm, viết CLI nhận notifications và hiển thị progress bar ASCII đẹp mắt (với ETA, rate).
Tóm tắt bài học
- Log & Progress là 2 tool khác nhau cùng mục đích — log cho text milestone, progress cho %.
- Server-side chỉ cần Context —
ctx.info(),ctx.report_progress(). 2-3 dòng code. - Client-side setup 2 callback —
logging_callbackper session,progress_callbackper tool call. - Cadence sweet spot: 1-2 update/giây — quá thưa user sợ treo, quá dày spam + overhead.
- Notifications phụ thuộc transport flags — HTTP
stateless_http=Truehoặcjson_response=Truecó thể cắt đứt. Phải biết config của mình. - Low-hanging fruit UX — chi phí implement thấp, win user satisfaction rất cao.
Tài liệu tham khảo
- MCP Spec — Logging notifications
- MCP Spec — Progress notifications
- Python SDK Context API — source của
Contextclass - Rich library for CLI progress — khuyến nghị cho CLI client