跳转至

MCP实战案例

前面的章节都是讲单个概念,本篇用一个完整的实战案例串起来:构建一个"博客助手 MCP Server",让 Claude 帮你管理博客文章。

1. 需求分析

我们要让 Claude 能完成以下任务:

  1. 列出所有文章 - 看博客都写过什么
  2. 读取某篇文章 - 把内容加载为上下文
  3. 创建新文章 - 给定标题和内容自动建立文件
  4. 搜索文章 - 关键词检索
  5. 生成文章模板 - 提供文章标题和大纲,让 LLM 写初稿
  6. 统计写作情况 - 看本月写了多少篇

涉及 MCP 的全部三个原语:

  • Tools:创建、搜索、统计(有副作用或动态计算)
  • Resources:列出和读取文章(只读数据)
  • Prompts:文章模板(提示词工程)

2. 项目结构

blog-mcp/
├── pyproject.toml
├── server.py
├── README.md
└── posts/                # 文章目录(用户实际博客)
    ├── post1.md
    └── post2.md

3. 完整代码

新建 server.py

import os
import re
import json
import datetime
from pathlib import Path
from typing import Annotated

from pydantic import Field
from mcp.server.fastmcp import FastMCP, Context

# === 配置 ===
BLOG_DIR = Path(os.environ.get("BLOG_DIR", "./posts")).resolve()
BLOG_DIR.mkdir(parents=True, exist_ok=True)

mcp = FastMCP("blog-helper")


# ==================== Tools ====================

@mcp.tool()
async def list_posts(
    limit: Annotated[int, Field(description="最多返回多少篇", ge=1, le=200)] = 20,
) -> list[dict]:
    """列出博客所有文章的元信息(标题、文件名、修改时间)。

    适用场景:用户询问"我都写了哪些文章"、"最近写了什么"。
    """
    posts = []
    for md_file in BLOG_DIR.glob("*.md"):
        title = extract_title(md_file)
        posts.append({
            "filename": md_file.name,
            "title": title,
            "modified": datetime.datetime.fromtimestamp(
                md_file.stat().st_mtime
            ).strftime("%Y-%m-%d %H:%M"),
            "size": md_file.stat().st_size,
        })

    # 按修改时间倒序
    posts.sort(key=lambda x: x["modified"], reverse=True)
    return posts[:limit]


@mcp.tool()
async def search_posts(
    keyword: Annotated[str, Field(description="搜索关键词")],
    case_sensitive: Annotated[bool, Field(description="是否区分大小写")] = False,
) -> list[dict]:
    """在所有文章中搜索关键词,返回匹配的文件和上下文片段。

    适用场景:用户问"我之前写过关于 XXX 的文章吗"、"搜索关于 YYY 的内容"。
    """
    results = []
    pattern = keyword if case_sensitive else keyword.lower()

    for md_file in BLOG_DIR.glob("*.md"):
        content = md_file.read_text(encoding="utf-8")
        text = content if case_sensitive else content.lower()

        if pattern in text:
            # 提取上下文片段
            idx = text.find(pattern)
            start = max(0, idx - 50)
            end = min(len(content), idx + 100)
            snippet = content[start:end].replace("\n", " ")

            results.append({
                "filename": md_file.name,
                "title": extract_title(md_file),
                "snippet": f"...{snippet}...",
            })

    return results


@mcp.tool()
async def create_post(
    title: Annotated[str, Field(description="文章标题")],
    content: Annotated[str, Field(description="Markdown 内容")],
    filename: Annotated[
        str | None,
        Field(description="文件名(可选),不填则根据标题自动生成")
    ] = None,
) -> dict:
    """创建一篇新文章。返回创建后的文件信息。

    适用场景:用户写了文章正文要保存、或者要求"帮我创建一篇关于 XXX 的文章"。
    """
    if filename is None:
        # 根据标题生成安全的文件名
        safe_title = re.sub(r'[^\w一-龥-]', '_', title)
        date_prefix = datetime.date.today().strftime("%Y-%m-%d")
        filename = f"{date_prefix}-{safe_title}.md"

    if not filename.endswith(".md"):
        filename += ".md"

    file_path = BLOG_DIR / filename

    if file_path.exists():
        raise ValueError(f"文件 {filename} 已存在,请换个文件名或先删除")

    # 添加 frontmatter
    full_content = f"""---
title: {title}
date: {datetime.date.today().isoformat()}
---

# {title}

{content}
"""
    file_path.write_text(full_content, encoding="utf-8")

    return {
        "filename": filename,
        "path": str(file_path),
        "size": file_path.stat().st_size,
    }


@mcp.tool()
async def get_writing_stats(
    days: Annotated[int, Field(description="统计最近多少天", ge=1, le=365)] = 30,
) -> dict:
    """统计指定天数内的写作情况。

    适用场景:用户问"我这个月写了几篇"、"最近的写作情况"。
    """
    cutoff = datetime.datetime.now() - datetime.timedelta(days=days)
    posts = list(BLOG_DIR.glob("*.md"))

    recent = [p for p in posts if datetime.datetime.fromtimestamp(p.stat().st_mtime) > cutoff]
    total_chars = sum(len(p.read_text(encoding="utf-8")) for p in recent)

    return {
        "period_days": days,
        "total_posts": len(recent),
        "total_chars": total_chars,
        "avg_chars_per_post": total_chars // max(len(recent), 1),
        "post_titles": [extract_title(p) for p in recent],
    }


# ==================== Resources ====================

@mcp.list_resources()
def list_post_resources():
    """让客户端能在资源浏览器中看到所有文章"""
    return [
        {
            "uri": f"post://{md_file.name}",
            "name": extract_title(md_file),
            "mimeType": "text/markdown",
        }
        for md_file in BLOG_DIR.glob("*.md")
    ]


@mcp.resource("post://{filename}")
def read_post(filename: str) -> str:
    """读取一篇文章的完整内容"""
    file_path = BLOG_DIR / filename

    # 路径安全检查
    if not str(file_path.resolve()).startswith(str(BLOG_DIR.resolve())):
        raise PermissionError("非法路径")

    if not file_path.exists():
        raise ValueError(f"文章 {filename} 不存在")

    return file_path.read_text(encoding="utf-8")


@mcp.resource("blog://stats", mime_type="application/json")
def blog_overview() -> str:
    """博客整体统计信息"""
    posts = list(BLOG_DIR.glob("*.md"))
    return json.dumps({
        "total_posts": len(posts),
        "blog_dir": str(BLOG_DIR),
    }, ensure_ascii=False, indent=2)


# ==================== Prompts ====================

@mcp.prompt()
def write_article_draft(
    topic: Annotated[str, Field(description="文章主题")],
    style: Annotated[str, Field(description="写作风格:技术/随笔/教程")] = "技术",
    target_length: Annotated[str, Field(description="目标长度:短/中/长")] = "中",
) -> str:
    """生成一篇文章的初稿"""
    length_map = {
        "短": "800 字左右",
        "中": "1500-2500 字",
        "长": "3000 字以上",
    }

    return f"""请围绕主题"{topic}"写一篇{style}风格的博客文章,长度约 {length_map.get(target_length, "1500 字")}

要求:
1. 标题吸引人、有信息量
2. 开头用 1-2 句话点明文章核心价值
3. 主体部分逻辑清晰,分段合理,必要时用代码示例
4. 结尾用 3-5 个要点总结
5. 使用 Markdown 格式
6. 适合在个人技术博客发布

只输出 Markdown 内容,第一行是 # 标题,后面是正文。
"""


@mcp.prompt()
def review_article(filename: str) -> str:
    """对一篇已有文章进行写作评审"""
    return f"""请阅读文章(资源 URI: post://{filename})并给出写作评审。

评审维度:
1. **标题**:是否吸引人,是否准确
2. **结构**:开头、主体、结尾是否清晰
3. **内容**:技术深度是否合理,有无明显错误
4. **可读性**:行文是否流畅,是否有冗余
5. **代码示例**:是否有代码、代码是否正确

请给出:
- 总体打分(1-10)
- 三个具体的改进建议
- 如果有明显错误,指出在哪一段
"""


# ==================== 工具函数 ====================

def extract_title(md_file: Path) -> str:
    """从 Markdown 文件提取标题"""
    try:
        content = md_file.read_text(encoding="utf-8")
        # 优先从 frontmatter 找
        m = re.search(r"^title:\s*(.+)$", content, re.MULTILINE)
        if m:
            return m.group(1).strip()
        # 否则从第一个 # 找
        m = re.search(r"^#\s+(.+)$", content, re.MULTILINE)
        if m:
            return m.group(1).strip()
        return md_file.stem
    except Exception:
        return md_file.stem


# ==================== 启动 ====================

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

4. 测试 Server

准备测试数据

mkdir -p posts
cat > posts/2026-04-01-hello.md << 'EOF'
---
title: 我的第一篇博客
date: 2026-04-01
---

# 我的第一篇博客

这是一篇测试文章,记录使用 MCP 的过程。
EOF

用 inspector 调试

BLOG_DIR=./posts mcp dev server.py

在浏览器中:

  1. 切到 Tools 标签,调用 list_posts(),应该返回刚创建的文章
  2. 调用 search_posts(keyword="MCP"),应该匹配到
  3. 调用 create_post(title="新文章", content="正文"),看看 posts 目录有没有新文件

5. 接入 Claude Desktop

编辑 claude_desktop_config.json

{
  "mcpServers": {
    "blog-helper": {
      "command": "python",
      "args": ["/Users/me/path/to/blog-mcp/server.py"],
      "env": {
        "BLOG_DIR": "/Users/me/Documents/blog/posts"
      }
    }
  }
}

完全退出 Claude Desktop 后重启。

6. 实际使用场景

场景 1:查看历史文章

用户: 我最近写了什么?
Claude: [自动调用 list_posts]
       你最近的 5 篇文章是:
       1. 《MCP 实战案例》(2026-04-25)
       2. 《Python 装饰器》(2026-04-20)
       ...

场景 2:搜索关键词

用户: 我之前写过关于 LangChain 的文章吗?
Claude: [自动调用 search_posts(keyword="LangChain")]
       找到 3 篇相关文章...

场景 3:写新文章

用户: /write_article_draft
       topic: "Python 协程详解"
       style: "技术"
       target_length: "中"

Claude: # Python 协程详解
       ...(完整文章初稿)

用户: 不错,帮我保存为新文章
Claude: [调用 create_post]
       已创建 2026-04-27-Python_协程详解.md

场景 4:评审已有文章

用户: /review_article
       filename: "2026-04-20-python-decorator.md"

Claude: [自动读取 post://2026-04-20-python-decorator.md]
       这篇文章总体评分 7/10。优点是...
       三个改进建议:
       1. ...

场景 5:写作统计

用户: 我这个月写得怎么样?
Claude: [自动调用 get_writing_stats(days=30)]
       本月你写了 8 篇文章,共 12,340 字,平均每篇 1542 字。

7. 进阶改进点

这个 Server 还可以扩展:

1. 文章发布

加一个 publish_post Tool,自动 git commit + push 到博客仓库。

2. 标签系统

add_taglist_by_tag,按标签管理。

3. SEO 优化

seo_check Tool,分析文章的 SEO 友好度。

4. 草稿管理

posts/ 拆为 drafts/published/,加状态切换 Tool。

5. 对接外部服务

  • langchain-mcp-adapters 把这个 Server 接入 LangChain Agent
  • 部署成 Streamable HTTP,多人共享

8. 经验总结

1. 三大原语各司其职

  • Tool:用户问什么 LLM 自己用什么(list_postssearch_posts
  • Resource:让用户主动选择上下文(点击 📎 选择某篇文章)
  • Prompt:高质量提示词工程(写文章、评审文章)

2. 安全很重要

文件路径必须做边界检查,否则 LLM 可能被 prompt injection 诱导读其他目录。

3. 描述要细

每个 Tool 加上"适用场景"说明,模型才知道什么时候调用。

4. 配置可外部化

用环境变量传 BLOG_DIR,让用户配置自己的目录,不要硬编码。

5. 错误信息要友好

raise ValueError("文章 xxx.md 不存在")FileNotFoundError 信息量大得多,模型可以根据错误调整下一步动作。

总结

  • 一个完整的 MCP Server 通常综合使用 Tools、Resources、Prompts 三大原语
  • 设计原则:Tool 让模型自主操作,Resource 让用户加载数据,Prompt 提供高质量提示
  • 真实场景下务必做安全检查(路径、权限、参数验证)
  • 用环境变量管理用户特定的配置(数据目录、API key 等)
  • 一个 Server 能让 Claude 完整掌管一个具体场景,远比单纯聊天有用

评论