Log & Progress notifications — Cho user thấy tool đang làm gì

Notifications (User Experience)Cơ bản20 phút

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.".

Bạn sẽ học được
  • 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 report

Sau 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 report

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:

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ợ):

LevelDùng khiVí dụ
debugChi tiết sâu cho dev"Cache hit for URL X"
infoProgress, milestone"Fetched 20 sources"
warningCó issue nhưng vẫn chạy được"Rate limit, retrying in 5s"
errorLỗ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).
ConfigLog (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.

Tài liệu tham khảo
  • 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
Nội dung này có hữu ích không?