Lộ trình học›MCP›MCP: Advanced Topics Mở bản đồ

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

Bài viếtCơ bản → Trung c20 phút đọc

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

Sau bài này, bạn sẽ
  1. 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).
  2. Thêm notifications vào tool Python chỉ với 2-3 dòng code dùng Context object.
  3. Viết callback phía client để xử lý notification (CLI, web, desktop).
  4. Thiết kế progress cadence hợp lý (không quá dày, không quá thưa).
  5. 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:

  1. Fetch 20 URL từ web.
  2. Parse HTML, clean content.
  3. Gửi 20 chunk vào LLM để extract key points.
  4. Synthesize thành báo cáo.
  5. 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: 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.

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

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

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"

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:

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

Nếu user báo "không thấy progress":

  1. Check config flag.
  2. Check client có đăng ký callback không.
  3. 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_id mỗ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 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.

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

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

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à warning khô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

Những điều cốt lõi
  • 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

Kiểm tra nhanh2 câu hỏi · chọn đáp án để xem giải thích
    Bạn đã hoàn thành bài này chưa?Tiến độ lưu trên trình duyệt của bạn.

    Khóa học cùng lộ trình