MCP 实战:从零构建一个可调用本地工具的 AI Agent

2026/08/25 AI Python 共 4661 字,约 14 分钟

💡 MCP 解决的不是“让模型更聪明”,而是让模型以统一协议调用真实工具。本文从一个只读的本地笔记搜索器开始,把一个最小可用的 MCP Server 跑起来。

先看结果

完成本文后,你会得到一个本地 MCP Server:

AI Agent / MCP Inspector
          │ tools/list、tools/call
          ▼
本地 MCP Server
          │
          ▼
只读搜索 ./notes 下的 Markdown 文件

它可以回答类似的问题:

“在我的笔记里搜索 retry,返回相关段落。”

工具只读 notes 目录,不执行 Shell,不访问目录之外的文件。这种边界很重要:工具一旦能写文件、发请求或执行命令,就已经进入需要权限控制和人工确认的范围。

MCP 到底解决什么问题

传统做法通常是给每个模型客户端写一套插件接口:

客户端 A → A 的插件格式
客户端 B → B 的插件格式
客户端 C → C 的插件格式

MCP 把工具、资源和提示词抽象成统一的 Server/Client 协议:

模型客户端 → MCP Client → MCP Server → 本地文件、数据库或 API

MCP Server 不负责“理解用户意图”,它只负责声明能力并执行调用。模型先看到工具名称、描述和 JSON Schema,再决定是否调用;客户端负责把调用结果交回模型。

工具调用的核心消息是两个 JSON-RPC 方法:

方法作用
tools/list列出工具名称、描述和 inputSchema
tools/call按工具名和参数执行一次调用

因此,一个好工具的关键不是代码量,而是三件事:名称清晰、参数可校验、失败结果可理解。

准备环境

本文使用 Python 3.10+ 和 uv。没有 uv 时,也可以把命令中的 uv run 换成已激活虚拟环境里的 python

mkdir mcp-local-tools
cd mcp-local-tools
uv init --python 3.12
uv add "mcp[cli]"
mkdir notes
Set-Content notes\python.md "Python 的 pathlib 可以安全处理文件路径。"
Set-Content notes\mcp.md "MCP Server 通过工具把能力暴露给客户端。"

项目目录应该是:

mcp-local-tools/
├── pyproject.toml
├── server.py
└── notes/
    ├── mcp.md
    └── python.md

写第一个 MCP Server

新建 server.py,完整内容如下:

from __future__ import annotations

from pathlib import Path

from mcp.server.fastmcp import FastMCP


BASE_DIR = Path(__file__).resolve().parent
NOTES_DIR = (BASE_DIR / "notes").resolve()
MAX_RESULTS = 20
MAX_CHARS_PER_FILE = 8_000

mcp = FastMCP("local-notes")


def safe_note_path(relative_path: str) -> Path:
    """把用户输入限制在 notes 目录内,阻止 .. 越界读取。"""
    candidate = (NOTES_DIR / relative_path).resolve()
    if candidate == NOTES_DIR or NOTES_DIR not in candidate.parents:
        raise ValueError("只能访问 notes 目录中的文件")
    if candidate.suffix.lower() != ".md":
        raise ValueError("只允许读取 Markdown 文件")
    return candidate


@mcp.tool()
def search_notes(query: str, limit: int = 10) -> str:
    """在本地 Markdown 笔记中搜索关键词,返回文件名和匹配行。"""
    query = query.strip()
    if not query:
        raise ValueError("query 不能为空")
    if len(query) > 100:
        raise ValueError("query 最多 100 个字符")
    limit = max(1, min(limit, MAX_RESULTS))

    results: list[str] = []
    for path in sorted(NOTES_DIR.rglob("*.md")):
        # 目录中可能存在损坏或非 UTF-8 文件,单个文件失败不应中断整次搜索。
        try:
            text = path.read_text(encoding="utf-8")[:MAX_CHARS_PER_FILE]
        except OSError:
            continue

        for line_number, line in enumerate(text.splitlines(), start=1):
            if query.casefold() in line.casefold():
                relative = path.relative_to(NOTES_DIR)
                results.append(f"{relative}:{line_number}: {line.strip()}")
                if len(results) >= limit:
                    return "\n".join(results)

    return "没有找到匹配内容。"


@mcp.tool()
def read_note(relative_path: str) -> str:
    """读取 notes 目录中的一个 Markdown 文件。"""
    path = safe_note_path(relative_path)
    if not path.is_file():
        raise ValueError("文件不存在")
    return path.read_text(encoding="utf-8")[:MAX_CHARS_PER_FILE]


if __name__ == "__main__":
    # 默认使用 stdio,适合桌面客户端启动子进程。
    mcp.run()

启动代码只有一行,但工具定义里的限制不能省略:

  • resolve() + parents 检查阻止 ../../secret.txt 这类路径穿越。
  • 后缀限制避免把任意文件当笔记读取。
  • 查询长度、结果数量和单文件大小都有上限,避免一次调用拖垮进程。
  • 读取单个文件失败时跳过,而不是把整个工具调用变成无意义的堆栈信息。

用 Inspector 调试

先用官方 CLI 启动 MCP Inspector:

uv run mcp dev server.py

浏览器打开 CLI 输出的地址,在工具列表里应该能看到:

search_notes(query, limit)
read_note(relative_path)

调用 search_notes 时传入:

{
  "query": "MCP",
  "limit": 5
}

预期结果类似:

mcp.md:1: MCP Server 通过工具把能力暴露给客户端。

Inspector 适合验证三件事:工具是否被发现、输入 Schema 是否生效、错误是否能返回给客户端。不要一上来就接入真实生产数据,先用示例目录验证边界。

连接桌面客户端

支持 MCP 的客户端通常需要一个 Server 配置。以使用 stdio 子进程的配置为例:

{
  "mcpServers": {
    "local-notes": {
      "command": "uv",
      "args": [
        "run",
        "--directory",
        "C:/path/to/mcp-local-tools",
        "mcp",
        "run",
        "server.py"
      ]
    }
  }
}

Windows 路径建议使用 /,或者把反斜杠写成 \\。配置中的路径必须替换成你自己的绝对路径,不能照抄示例路径。

启动客户端后,模型会先通过 tools/list 发现两个工具。当用户提出“搜索笔记”或“读取某篇笔记”时,客户端再发送 tools/call。模型并不会直接获得你的文件系统权限,它只能使用 Server 主动暴露的能力。

改成 Streamable HTTP

stdio 适合本机单用户;需要多个客户端或容器部署时,可以使用 Streamable HTTP:

uv run mcp run server.py --transport streamable-http

默认监听地址和端口以 SDK/CLI 输出为准。部署到网络环境时,至少补上:

  1. 身份认证和授权,不能把本地文件工具直接暴露到公网。
  2. HTTPS,避免笔记内容和工具参数明文传输。
  3. 请求超时、并发限制和审计日志。
  4. 每次调用前检查用户是否仍有权访问对应资源。

MCP 的传输层解决“消息怎么传”,不替你解决“谁能调用”。权限必须在 Server 和部署层分别实现。

让它成为真正的 Agent

MCP Server 只是工具提供方。一个 Agent 的最小循环是:

sequenceDiagram
    participant U as 用户
    participant M as 模型
    participant C as MCP Client
    participant S as MCP Server
    U->>M: 搜索笔记中的 MCP
    M->>C: 选择 search_notes
    C->>S: tools/call(query=MCP)
    S-->>C: 返回匹配行
    C-->>M: 工具结果
    M-->>U: 总结并引用文件位置

循环可以概括为:

  1. 客户端获取工具清单和 Schema。
  2. 模型根据用户问题选择工具并生成参数。
  3. 客户端展示或审核调用,再发送 tools/call
  4. Server 校验参数并执行操作。
  5. 工具结果回到模型,模型生成最终回答。

涉及写文件、发邮件、执行命令、修改数据库时,应在第 3 步增加明确的人工确认;不要因为“模型说要做”就自动执行不可逆操作。

常见坑

把工具描述写成营销文案

模型靠工具描述决定是否调用。描述应该说明输入、输出、限制和失败条件,例如“只读取 notes 下的 Markdown 文件”,而不是“智能管理你的所有文件”。

让 Server 自己解析自然语言

工具参数应保持结构化:querylimitrelative_path。自然语言理解交给模型,Server 负责验证已经结构化的参数。

忽略错误结果

错误消息要能帮助模型修正参数,例如返回“只允许读取 Markdown 文件”,不要只返回 ValueError 或一大段堆栈。

把 token 放进工具参数

密钥、Cookie、PAT 不应成为模型可见的普通参数。放到 Server 的环境变量或密钥管理系统中,并避免把敏感值写入日志和工具结果。

把本地工具当成可信代码

MCP 只是协议,不会自动给代码增加安全性。第三方 Server 仍要审查源码、锁定依赖版本、限制文件范围,并在沙箱或低权限账户中运行。

最小检查清单

  • 工具输入是否有 JSON Schema 和长度/数量上限?
  • 是否限制了文件、网络和命令的访问范围?
  • 是否对写操作、外发请求和敏感数据增加人工确认?
  • 错误消息是否能帮助模型修正参数?
  • 是否记录调用者、工具名、参数摘要、耗时和结果状态?
  • Server 依赖是否锁版本并定期更新?

总结

MCP 的价值在于把“模型能调用什么”变成一个清晰、可发现、可审计的协议层。一个可靠的落地顺序是:

只读工具 → 参数校验 → Inspector 调试 → 客户端接入 → 权限与审计 → 谨慎开放写操作

先从一个只读工具开始,比一次性暴露“执行任意命令”的万能 Agent 更容易验证,也更容易控制风险。

参考资料


作者:牛马便利店一号店员

文档信息

Search

    Table of Contents