首页 › tutorials › 让MCP工具实时回报进度与日志

让 MCP 工具实时回报进度与日志:FastMCP Context 实战

概述

本教程教你用 Python 的 FastMCP 框架构建一个"会反馈"的 MCP 工具服务器。重点是大多数人踩坑的 Context 上下文对象:让长时间运行的工具向 Claude Code 实时回报进度条、回流结构化日志,并在工具内部读取兄弟资源。学完你能交付一个带进度反馈的大日志分析服务器,并用 pytest 单测、Inspector 可视化调试,最后接入 Claude Code 观察终端里的实时进度。适合已跑通 Claude Code、想从"调用别人的 MCP"进阶到"造自己的 MCP 工具"的开发者。

前置条件


一、为什么大多数 MCP 工具是"黑盒"

很多人写完第一个 MCP 工具后的体验是这样的:工具被调用了,然后……Claude Code 转圈圈,几十秒到几分钟没有任何反馈,你不知道它是在正常干活、卡死了、还是在跑一个巨大的循环。

根因在于:MCP 工具默认是"一次性请求—一次性返回"模型。函数签名 def foo(...) -> str,调用方只能等它跑完拿到最终结果。这在秒级任务里没问题,但遇到解析大日志、批量重命名、跑测试套件、爬取分页数据这类几十秒到分钟级的任务,黑盒体验就直接劝退用户。

MCP 协议其实预留了解法——服务端通知(notifications)。FastMCP 把它封装成一个优雅的 Context 对象,只要在工具函数里多声明一个参数 ctx: Context,你就解锁了三件事:

Context 能力 方法 效果
进度上报 await ctx.report_progress(current, total) 在 Claude Code 终端渲染进度条
日志回流 await ctx.info/debug/warning/error(msg) 把服务端日志推给客户端,用户可见
资源读取 await ctx.read_resource(uri) 在工具内部读取本服务暴露的资源

这三样恰恰是已有 MCP 教程最少覆盖的角落,也是把一个"玩具工具"升级为"生产可用工具"的关键一跃。

flowchart LR
    U[用户:分析这批日志] --> CC[Claude Code]
    CC -->|tools/call| T[analyze_log 工具]
    T -->|ctx.report_progress| CC
    T -->|ctx.info 日志| CC
    T -->|ctx.read_resource| R[config 资源]
    R --> T
    T -->|每 1000 行回传进度| CC
    T -->|最终结果| CC
    CC --> U
    style T fill:#fef3c7
    style CC fill:#dbeafe

二、环境准备:30 秒起一个 FastMCP 项目

FastMCP 在 2026 年有两个等价入口:Anthropic 官方 mcp 包(from mcp.server.fastmcp import FastMCP),以及 PrefectHQ 维护、特性最全的独立 fastmcp 包。本教程用后者——它的 Context 文档与测试工具链最完善,且与官方 SDK API 完全一致,迁移零成本。

用 uv 创建一个隔离项目:

uv init log-analyzer && cd log-analyzer
uv add fastmcp pytest pytest-asyncio

如果你坚持用官方包,把下面的 from fastmcp import ... 换成 from mcp.server.fastmcp import FastMCP, Context,其余代码一字不改。

确认安装成功:

uv run python -c "import fastmcp; print(fastmcp.__version__)"

预期结果:打印版本号(如 3.2.x),无报错。

常见问题: - ModuleNotFoundError: fastmcp —— 你直接用了系统 python 而非 uv run。所有命令都带 uv run 前缀。 - Python < 3.11 报 TypeError: FastMCP —— FastMCP 依赖 3.10+ 的类型语法,升级 Python。


三、最小可用:一个会报进度的工具

先写一个能体现核心价值的 20 行服务器 server.py:

from fastmcp import FastMCP, Context

mcp = FastMCP("log-analyzer")


@mcp.tool
async def count_lines(file_path: str, ctx: Context) -> str:
    """逐行统计文件行数,并实时回报进度。"""
    with open(file_path, encoding="utf-8") as f:
        total = sum(1 for _ in f)

    await ctx.info(f"开始统计 {file_path},共约 {total} 行")
    processed = 0
    with open(file_path, encoding="utf-8") as f:
        for line in f:
            processed += 1
            if processed % 20 == 0:                       # 每 20 行回传一次进度
                await ctx.report_progress(processed, total)
    await ctx.report_progress(total, total)
    return f"完成:{file_path} 共 {processed} 行"


if __name__ == "__main__":
    mcp.run(transport="stdio")

注意三个关键点: 1. @mcp.tool 不带括号也能用(FastMCP 2.x+ 装饰器零参数时省略括号)。 2. ctx: Context 是按类型注入的,调用方(Claude)不需要传它——框架自动填。 3. report_progress 和 info 都是协程,必须 await。漏写 await 是最高频 bug:代码"看起来跑通了",但客户端永远收不到进度。

预期结果:先用内置 Client 自测,确认能收进度:

# quick_check.py
import asyncio
from fastmcp import Client
from server import mcp

async def main():
    async with Client(mcp) as client:
        async for msg in client.call_tool("count_lines",
                                          {"file_path": "server.py"},
                                          progress_handler=print):
            pass

asyncio.run(main())
uv run python quick_check.py

progress_handler=print 会把每一条进度通知打印成 (progress, total, message) 元组。

常见问题: - 进度回调不触发 —— 检查 await 是否漏写,以及 if processed % 20 == 0 的步长是否过大(测试文件太小,20 行都没到就结束了)。 - RuntimeError: no running event loop —— 顶层没有 asyncio.run(...) 包裹。


四、实战项目:大日志分析服务器

现在构建一个完整、可交付的 MCP 服务器。它做一件真实的事:分析服务器日志,按级别统计、抓取最近的 ERROR 上下文,并把结果交给 Claude 总结。整个过程中用户能实时看到"已处理到第几行"。

项目结构:

log-analyzer/
├── server.py          # MCP 服务器(tool + resource + prompt)
├── sample.log         # 测试用日志
├── test_server.py     # pytest 单测
├── .mcp.json          # Claude Code 接入配置
└── pyproject.toml

4.1 完整 server.py

from __future__ import annotations
from pathlib import Path
from collections import Counter
from fastmcp import FastMCP, Context

mcp = FastMCP("log-analyzer")

# 配置:通过 resource 暴露,工具内用 ctx.read_resource 读取
SETTINGS = {
    "context_lines": "3",   # ERROR 上下文窗口
    "max_errors": "50",     # 最多抓取多少条 ERROR
}


@mcp.tool
async def analyze_log(file_path: str, ctx: Context) -> str:
    """分析日志文件,统计各级别数量并抓取最近的 ERROR 上下文。

    长时间运行,会通过进度通知实时回报处理进度。
    """
    p = Path(file_path)
    if not p.exists():
        await ctx.warning(f"文件不存在:{file_path}")
        return f"错误:找不到文件 {file_path}"

    # —— 演示在工具内读取兄弟资源 ——
    cfg = await ctx.read_resource("config://analyzer/context_lines")
    context_lines = int(cfg[0].text) if cfg else 3

    total = sum(1 for _ in p.open(encoding="utf-8", errors="replace"))
    await ctx.info(f"开始分析 {file_path},共 {total} 行,上下文窗口 {context_lines}")

    counter: Counter[str] = Counter()
    error_snippets: list[str] = []
    window: list[str] = []

    with p.open(encoding="utf-8", errors="replace") as f:
        for i, line in enumerate(f, 1):
            window.append(line.rstrip())
            if len(window) > context_lines + 1:
                window.pop(0)

            for level in ("ERROR", "WARN", "INFO", "DEBUG"):
                if level in line:
                    counter[level] += 1
            if "ERROR" in line and len(error_snippets) < 50:
                error_snippets.append("\n".join(window))

            if i % 2000 == 0:                              # 每 2000 行回传进度
                await ctx.report_progress(i, total,
                                          message=f"已处理 {i}/{total}")

    await ctx.report_progress(total, total, message="分析完成")
    await ctx.debug(f"抓取到 {len(error_snippets)} 条 ERROR 片段")

    summary = ", ".join(f"{k}={v}" for k, v in counter.most_common())
    tail = "\n---\n".join(error_snippets[-3:]) or "(无 ERROR)"
    return f"## 统计\n{summary}\n\n## 最近 ERROR 上下文\n{tail}"


@mcp.resource("config://analyzer/{key}")
def get_setting(key: str) -> str:
    """读取分析器运行配置(context_lines / max_errors)。"""
    return SETTINGS.get(key, "未设置")


@mcp.prompt
def summarize_errors(log: str) -> str:
    """让 Claude 总结日志中的错误模式与可能根因。"""
    return (
        "你是一名资深 SRE。请分析下面日志的 ERROR 上下文,"
        "归纳错误模式、给出 3 条最可能的根因假设,并按优先级排序:\n\n"
        f"{log}"
    )


if __name__ == "__main__":
    mcp.run(transport="stdio")

4.2 准备测试日志

uv run python -c "
import random
lv = ['INFO','INFO','INFO','WARN','ERROR','DEBUG']
with open('sample.log','w') as f:
    for i in range(12000):
        lvl = random.choice(lv)
        f.write(f'2026-06-19 10:{i%60:02d}:{i%60:02d} [{lvl}] event #{i} msg payload here\n')
print('sample.log 已生成 12000 行')
"

4.3 造一条真实 ERROR 便于验证

往 sample.log 末尾追加几行带 ERROR 的,确保 analyze_log 能抓到上下文:

printf '2026-06-19 11:00:00 [INFO] connection established\n2026-06-19 11:00:01 [ERROR] NullPointerException at com.app.Handler\n2026-06-19 11:00:02 [ERROR] DatabaseTimeout after 30s\n' >> sample.log

4.4 命令行自测(不进 Claude Code)

uv run fastmcp run server.py

这会以 stdio 启动并打印注册的 tools/resources/prompts 清单,确认 analyze_log、config://analyzer/{key}、summarize_errors 三个原语都在。

预期结果:终端列出 1 个 tool、1 个 resource、1 个 prompt,无异常退出。

常见问题: - read_resource 返回空列表 —— 资源 URI 模板必须和 @mcp.resource 注册的完全一致(包括 config:// 前缀和 {key} 占位符)。 - 进度在自测时看不到 —— stdio 模式下 stdout 被协议占用,进度走的是通知通道,需要在客户端(Inspector/Claude Code)里才能可视化。


五、用 FastMCP Client 写单元测试

MCP 服务器最大的测试优势是:不需要起子进程,Client 和 Server 可以在同一个进程内用内存通道通信,毫秒级往返、完全确定性。这也是 CI 里跑得最稳的形态。

test_server.py:

import pytest
from fastmcp import Client
from server import mcp


@pytest.fixture
def client():
    return Client(mcp)               # 进程内内存传输


@pytest.mark.asyncio
async def test_lists_three_primitives(client):
    async with client as c:
        tools = await c.list_tools()
        resources = await c.list_resources()
        prompts = await c.list_prompts()
    assert any(t.name == "analyze_log" for t in tools)
    assert any(r.uri.startswith("config://analyzer") for r in resources)
    assert any(p.name == "summarize_errors" for p in prompts)


@pytest.mark.asyncio
async def test_analyze_log_counts_levels(client):
    async with client as c:
        result = await c.call_tool("analyze_log", {"file_path": "sample.log"})
    text = result.content[0].text
    assert "统计" in text
    assert "ERROR=" in text              # 确实统计到了 ERROR


@pytest.mark.asyncio
async def test_analyze_log_missing_file(client):
    async with client as c:
        result = await c.call_tool("analyze_log", {"file_path": "nope.log"})
    assert "找不到文件" in result.content[0].text


@pytest.mark.asyncio
async def test_progress_is_reported(client):
    progress = []
    async with client as c:
        await c.call_tool(
            "analyze_log",
            {"file_path": "sample.log"},
            progress_handler=lambda p, t, m=None: progress.append((p, t)),
        )
    assert progress, "应当至少回传一次进度"
    assert progress[-1][0] == progress[-1][1]   # 最后一次 = 100%


@pytest.mark.asyncio
async def test_resource_read(client):
    async with client as c:
        content = await c.read_resource("config://analyzer/context_lines")
    assert content[0].text == "3"

运行:

uv run pytest -v

预期结果:5 个测试全部 PASSED,耗时通常 < 1 秒(因为没有进程开销)。

test_progress_is_reported 是这一节的核心——它用 progress_handler 断言进度通知真的被发出了,这是普通 MCP 测试教程不写的"反向验证"。

常见问题: - PytestUnraisableExceptionWarning: coroutine never awaited —— 函数漏了 async,或漏了 @pytest.mark.asyncio。 - 测试卡住不动 —— 检查 async with client as c 是否成对,未关闭的 Client 会阻塞事件循环。


六、Inspector 可视化调试

单测覆盖了逻辑,但有时你需要"手动戳一下"看真实返回。MCP Inspector 提供浏览器 UI:

npx @modelcontextprotocol/inspector uv run server.py

打开 http://localhost:6274,你会看到:

如果 Inspector 里进度条不动,几乎可以锁定问题在服务端(漏 await 或步长太大),而不是 Claude Code 的问题。这是"先在 Inspector 复现,再回 Claude Code"的标准排障路径。


七、接入 Claude Code 并观察终端进度

7.1 注册到 Claude Code

claude mcp add log-analyzer -- uv run --directory $(pwd) server.py

--directory 很关键:MCP 服务器是 Claude Code 拉起的子进程,它的工作目录不一定是你的项目根目录,显式指定能避免"找不到 server.py / 依赖没装"。

用 local 作用域(默认)会把配置写进项目级 .mcp.json,可随仓库分享。等价的 .mcp.json 手写版:

{
  "mcpServers": {
    "log-analyzer": {
      "command": "uv",
      "args": ["run", "--directory", "/绝对路径/log-analyzer", "server.py"],
      "env": { "PYTHONUNBUFFERED": "1" }
    }
  }
}

PYTHONUNBUFFERED=1 让 stdout 不缓冲——进度通知能更及时地推送,避免"明明跑完了才一次性刷出来"。

7.2 验证连接

claude mcp list

预期结果:log-analyzer 显示为 ✓ connected,并列出 1 tool / 1 resource / 1 prompt。

如果是 ✗ failed,单独看详情:

claude mcp get log-analyzer

7.3 实际触发并看进度

在 Claude Code 里输入:

用 log-analyzer 分析 sample.log,告诉我 ERROR 都出现在哪。

Claude 会调用 analyze_log,此时终端会渲染一条不断刷新的进度条:[████████░░░░] 8000/12000 已处理 8000/12000,并穿插 ctx.info 推来的日志行。分析完成后,你可以紧接着让它用 summarize_errors prompt 做归因——同一个服务器,工具产出 → prompt 消费,闭环成立。


八、整体工作流程

flowchart TB
    subgraph 开发期
        A[写 server.py<br/>tool/resource/prompt] --> B[uv run pytest<br/>进程内单测]
        B --> C[npx inspector<br/>手动验证进度]
    end
    subgraph 接入期
        C --> D[claude mcp add<br/>或写 .mcp.json]
        D --> E[claude mcp list<br/>确认 connected]
    end
    subgraph 运行期
        E --> F[Claude 调用 analyze_log]
        F --> G[ctx.report_progress<br/>终端进度条]
        F --> H[ctx.info/debug<br/>结构化日志回流]
        F --> I[ctx.read_resource<br/>读 config]
        G --> J[用户实时可见]
        H --> J
        I --> K[最终 Markdown 结果]
        K --> L[summarize_errors prompt 归因]
    end
    style F fill:#fef3c7
    style G fill:#dcfce7
    style H fill:#dcfce7

九、常见问题排错清单

现象 根因 解法
Claude Code 转圈无反馈 report_progress 漏 await 检查所有 ctx 方法前都有 await
Inspector 进度条不动 文件太小、步长太大没触发 改 if i % 50 == 0 或加大测试文件
mcp list 显示 failed 子进程工作目录不对/依赖未装 .mcp.json 加 --directory,确认 uv add 在同项目
stdout 出现乱码 JSON 在工具里 print() 污染了 stdio 禁用 print,日志一律走 ctx.info/debug
资源读取返回空 URI 模板占位符不匹配 config://analyzer/{key} 必须字字对应
测试随机失败 事件循环未正确关闭 确认 async with client 配对,用 pytest-asyncio 的 auto 模式

核心心法一句话:stdio 模式下 stdout 是协议通道,任何用户可见的反馈都必须走 ctx,而不是 print。


延伸阅读


训练营适用性评估


参考来源

  1. FastMCP Context 官方文档 — Context 对象的 logging/progress/resource 完整 API 参考
  2. FastMCP Progress Reporting — ctx.report_progress 的进度通知机制与示例
  3. FastMCP Testing Patterns — 进程内 Client/Server 测试、pytest-asyncio 配置与 progress_handler 写法
  4. MCP Python SDK (modelcontextprotocol/python-sdk) — 官方 mcp 包,from mcp.server.fastmcp import FastMCP 的源头实现
  5. FastMCP in Python: Build a Real MCP Server (2026 Guide) — FastMCP 3.2 生产级服务器实战(tools/resources/prompts 全流程)

[!tip] 51学通信知识星球 星主:爱卫生(微信:gprshome201101)——公众号「51学通信」创办者,19 年通信行业运维与讲师经验,著有《GPRS网络信令实例详解》《EPC原理与实践》《5G核心网原理与实践》三本纸质书(各大电商平台有售)。 星球定位:深耕 5G 核心网、IMS、NFV/AI、云原生等 ICT 领域,主打运维岗位与信令分析。 当前规模:5200 篇主题 / 990 个回答,198 元/年。 加入福利:加入星球后请加爱卫生微信,拉您进知识星球年度陪伴与答疑群——不限量回复您的各种问题,另有群内分享。 AI 加持:51学通信知识星球已作为 skill 接入 WorkBuddy,使用 deepseek-4-flash 在海量星球帖子中精准找贴、找视频、找 FAQ 和案例,做总结、找文档和 PPT 等场景全覆盖。

51学通信知识星球海报-扫码加入