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).
Hai loại notifications — Log vs Progress
MCP phân 2 loại notification cho UX này:
Khi nào dùng log?
Khi nào dùng progress?
Quy tắc: dùng cả hai. Log cho detail, progress cho visual. Chúng bổ sung nhau, không thay thế nhau.
- 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".
- 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%.
┌───────────────────────────────────────────────────────────┐
│ │
│ 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...") │
│ │
└───────────────────────────────────────────────────────────┘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
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
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 reportSau khi thêm notifications
Điểm đáng chú ý:
- ctx: Context là keyword-only argument (sau *,). Đây là convention của SDK.
- ctx.info() gửi log level info. Còn ctx.warning(), ctx.error(), ctx.debug().
- ctx.report_progress(current, total, message) — message optional, 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.
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 reportCode 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:
Chú ý 2 mức độ đăng ký:
- logging_callback gắn với session. Mọi log notification trong session sẽ đi qua callback này.
- progress_callback gắ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.
from mcp.client.stdio import stdio_client, StdioServerParameters
from mcp import ClientSession
from mcp.types import LoggingMessageNotificationParams
# Callback 1: log messages
async def logging_callback(params: LoggingMessageNotificationParams):
level = params.level.upper()
print(f"[{level}] {params.data}")
# Callback 2: progress
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())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
User thấy progress đứng ở 0% trong 3 phút → tưởng hỏng. Đừng làm vậy.
Quá dày
await ctx.report_progress(0, 100, "Starting")
# ... chạy 3 phút im lặng ...
await ctx.report_progress(100, 100, "Done")Quá dày
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): # 1000 urls
fetch(url)
await ctx.report_progress(i, 1000)Sweet spot: 10-30 update cho 1 tool call
Hoặc dùng thời gian:
# Fetch 1000 URLs, báo mỗi 50 URL
for i, url in enumerate(urls):
fetch(url)
if i % 50 == 0:
await ctx.report_progress(i, 1000, f"Fetched {i}/1000")Progress cadence — Nghệ thuật của tần suất (tiếp)
Quy tắc vàng: 1-2 update mỗi giây đủ cho UI mượt mà.
# Mỗi giây cập nhật một lần
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()Log level — Dùng đúng cho đúng context
MCP hỗ trợ 4 log level (giống Python logging):
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ì:
Dùng structured data (một số SDK hỗ trợ):
| 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" |
await ctx.info("Fetched 20 URLs")Mẹo: cấu trúc message dạng structured
Client có thể parse và render đẹp hơn (table, chart) thay vì plain text.
await ctx.info({
"event": "fetch_complete",
"count": 20,
"duration_ms": 1250
})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:
Nếu user báo "không thấy progress":
Đâ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.
- Check config flag.
- Check client có đăng ký callback không.
- Check proxy có buffer SSE không (mẹo bài 10.4).
| 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 | ❌ | ❌ |
Presentation patterns theo loại app
CLI app
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:
Ví dụ WebSocket pattern:
- 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_id mỗi giây. Đơn giản nhất nhưng latency cao.
# Simple: print ra terminal
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}")Web app (React/Vue)
Desktop app (Electron, Tauri)
Claude Desktop là một ví dụ. Có thể update UI component trực tiếp:
// 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)
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.
session.onProgress((p, t, msg) => {
store.setProgress({ current: p, total: t, message: msg });
});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:
🔍 Research tool — deep_research(topic)
Pain: 3-5 phút chạy, user không kiên nhẫn.
Giải pháp:
💰 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:
📣 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:
🏥 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:
⚖️ 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: 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.
- 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.
- Dùng log level debug cho chi tiết (cache hit, SQL query).
- info cho milestone (đã extract 10k rows, đã transform xong).
- warning cho retry.
- error cho 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.
- 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.
- Progress với 4 giai đoạn: "Fetching lab results (10 records)", "Parsing imaging reports (3 files)", "Cross-referencing prescriptions", "Generating summary".
- Log warning khi 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ờ.
- Progress với message prefix tên contract: "[ContractXYZ] Parsing clauses 20/80".
- Log info cho 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_forget pattern (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:
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
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}")
# Trong tool:
log = LogHelper(ctx, step_total=5)
await log.step_done("Fetching sources")
await log.step_done("Parsing HTML")
# ... v.v.Mẹo 3: Dùng progress total=None cho giai đoạn không biết trước
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(progress=0, total=None, message="Searching...")Mẹo 4: Nested progress cho long-running tool
Mẹo 5: Correlate log với trace ID
Nếu backend có distributed tracing, include trace ID trong log:
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
Dev có thể join log MCP với trace backend để debug end-to-end.
await ctx.info(f"[trace={trace_id}] Querying DB")Á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:
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:
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).
- Số notification bạn emit: ___________
- Cadence (khoảng cách giữa 2 progress): ___________
- Có log nào là warning không? ___________
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()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_callback per session, progress_callback per 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=True hoặc json_response=True có 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.
- MCP Spec — Logging notifications
- MCP Spec — Progress notifications
- Python SDK Context API — source của Context class
- Rich library for CLI progress — khuyến nghị cho CLI client