春江暮客

春江暮客的个人学习分享网站

Python MCP Server 安全实战:给 AI 工具调用加边界

2026-06-01 技术
Python MCP Server 安全实战:给 AI 工具调用加边界

MCP Server 很容易写出第一个版本,但真正接入 AI 客户端以后,另一个问题会马上出现:模型能调用工具,也可能把错误参数、越界路径或危险命令传给工具。

这篇文章的目标很明确:用 Python 和 FastMCP 写一个更安全的本地 MCP Server。它不追求复杂权限系统,只先做好四件实用的事:

  1. 限制工具只能访问项目目录
  2. 只允许读取指定后缀的文件
  3. 限制单次读取大小
  4. 记录每次工具调用的审计日志

方法 1:先定义安全边界

不要从“让 AI 能做所有事”开始,而是先写清楚它不能做什么

本文的示例工具只允许:

  1. 列出项目内的少量文本类文件
  2. 读取项目内的安全后缀文件
  3. 解析项目内的 JSON 配置键名

它不允许:

  1. 读取 ../ 路径逃逸到项目外
  2. 读取大文件
  3. 执行任意 shell 命令
  4. 返回没有限制的大量内容

这个边界很朴素,但足够覆盖很多本地开发、文档处理和配置检查场景。

方法 2:创建安全版 MCP Server

1. 准备环境

推荐用 uv 创建一个独立项目:

mkdir secure-mcp-tools
cd secure-mcp-tools
uv init
uv add "mcp[cli]"

准备一个测试文件:

printf '# Demo\n\nhello secure MCP\n' > README.md
printf '{"name":"demo","debug":true}\n' > config.json

确认 MCP 开发命令可用:

uv run mcp --help

如果能看到 dev 子命令,说明环境已经准备好。

2. 编写 server.py

新建 server.py

from __future__ import annotations

import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

from mcp.server.fastmcp import FastMCP


mcp = FastMCP("Secure Local Tools")

PROJECT_ROOT = Path.cwd().resolve()
AUDIT_LOG = PROJECT_ROOT / "mcp-audit.log"
ALLOWED_SUFFIXES = {".md", ".txt", ".json", ".yaml", ".yml", ".py"}
MAX_FILE_BYTES = 64 * 1024
MAX_LIST_RESULTS = 50


def audit(event: str, **fields: Any) -> None:
    record = {
        "time": datetime.now(timezone.utc).isoformat(),
        "event": event,
        **fields,
    }
    with AUDIT_LOG.open("a", encoding="utf-8") as fp:
        fp.write(json.dumps(record, ensure_ascii=False) + "\n")


def resolve_project_path(path: str) -> Path:
    if not path or path.startswith(("/", "~")) or "\x00" in path:
        raise ValueError("path must be a relative project path")

    target = (PROJECT_ROOT / path).resolve()

    try:
        target.relative_to(PROJECT_ROOT)
    except ValueError as exc:
        raise ValueError("path must stay inside the project") from exc

    return target


def check_readable_file(path: str) -> Path:
    target = resolve_project_path(path)

    if not target.exists() or not target.is_file():
        raise ValueError("file does not exist")

    if target.suffix.lower() not in ALLOWED_SUFFIXES:
        raise ValueError(f"suffix is not allowed: {target.suffix}")

    size = target.stat().st_size
    if size > MAX_FILE_BYTES:
        raise ValueError(f"file is too large: {size} bytes")

    return target


@mcp.tool()
def list_allowed_files(directory: str = ".") -> list[str]:
    """List safe text-like files under the project directory."""
    target = resolve_project_path(directory)

    if not target.exists() or not target.is_dir():
        raise ValueError("directory does not exist")

    results: list[str] = []
    for item in sorted(target.rglob("*")):
        if item.is_file() and item.suffix.lower() in ALLOWED_SUFFIXES:
            results.append(str(item.relative_to(PROJECT_ROOT)))
        if len(results) >= MAX_LIST_RESULTS:
            break

    audit("list_allowed_files", directory=directory, count=len(results))
    return results


@mcp.tool()
def read_allowed_file(path: str) -> dict[str, Any]:
    """Read one small allowlisted file inside the project directory."""
    target = check_readable_file(path)
    content = target.read_text(encoding="utf-8")

    audit("read_allowed_file", path=path, bytes=target.stat().st_size)
    return {
        "path": str(target.relative_to(PROJECT_ROOT)),
        "bytes": target.stat().st_size,
        "content": content,
    }


@mcp.tool()
def summarize_json_keys(path: str) -> dict[str, Any]:
    """Return top-level keys from a small JSON file."""
    target = check_readable_file(path)

    if target.suffix.lower() != ".json":
        raise ValueError("path must point to a JSON file")

    data = json.loads(target.read_text(encoding="utf-8"))
    if not isinstance(data, dict):
        raise ValueError("JSON root must be an object")

    keys = sorted(str(key) for key in data.keys())
    audit("summarize_json_keys", path=path, keys=len(keys))
    return {
        "path": str(target.relative_to(PROJECT_ROOT)),
        "keys": keys,
    }


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

这个版本的重点不是工具数量,而是每个工具都有明确边界:

  1. resolve_project_path() 防止路径逃逸
  2. ALLOWED_SUFFIXES 控制可读文件类型
  3. MAX_FILE_BYTES 控制单次返回内容大小
  4. audit() 把调用记录写入 mcp-audit.log

方法 3:调试和验证

启动 MCP Inspector:

uv run mcp dev server.py

在 Inspector 里先调用 list_allowed_files

{
  "directory": "."
}

预期可以看到类似结果:

[
  "README.md",
  "config.json"
]

再调用 read_allowed_file

{
  "path": "README.md"
}

预期返回:

{
  "path": "README.md",
  "bytes": 24,
  "content": "# Demo\n\nhello secure MCP\n"
}

测试 JSON 解析工具:

{
  "path": "config.json"
}

预期返回:

{
  "path": "config.json",
  "keys": [
    "debug",
    "name"
  ]
}

最后检查审计日志:

cat mcp-audit.log

输出会类似:

{"time":"2026-06-01T01:05:30.123456+00:00","event":"list_allowed_files","directory":".","count":2}
{"time":"2026-06-01T01:05:41.123456+00:00","event":"read_allowed_file","path":"README.md","bytes":24}

常见问题

错误 1:path must stay inside the project

如果客户端传入:

{
  "path": "../.ssh/id_rsa"
}

服务会拒绝这个请求。修复方式不是放开限制,而是把需要访问的文件复制到项目目录内,或者为特定目录单独设计一个更严格的工具。

错误 2:suffix is not allowed

如果要读取 .toml.csv,先确认业务确实需要,再把后缀加入白名单:

ALLOWED_SUFFIXES = {".md", ".txt", ".json", ".yaml", ".yml", ".py", ".toml"}

不要为了省事写成“允许所有后缀”。

错误 3:file is too large

大文件不要直接完整返回给模型。更好的做法是新增一个专用工具,只返回前 N 行、匹配行或结构化摘要。

例如:

@mcp.tool()
def head_file(path: str, lines: int = 40) -> list[str]:
    target = check_readable_file(path)
    lines = max(1, min(lines, 200))
    return target.read_text(encoding="utf-8").splitlines()[:lines]

错误 4:想让 MCP 执行命令

不要直接暴露这种工具:

import subprocess


@mcp.tool()
def run_command(command: str) -> str:
    return subprocess.check_output(command, shell=True, text=True)

如果确实需要执行命令,先做固定白名单:

import subprocess


ALLOWED_COMMANDS = {
    "git_status": ["git", "status", "--short"],
    "git_branch": ["git", "branch", "--show-current"],
}


@mcp.tool()
def run_safe_command(name: str) -> str:
    if name not in ALLOWED_COMMANDS:
        raise ValueError("command is not allowed")

    return subprocess.check_output(
        ALLOWED_COMMANDS[name],
        cwd=PROJECT_ROOT,
        text=True,
        timeout=10,
    )

重点是:让模型选择业务动作,不要让模型拼接任意 shell 字符串。

Summary

写 MCP Server 时,最危险的不是代码复杂,而是工具边界太宽。一个实用的安全版本,至少应该包含路径限制、类型白名单、大小限制和审计日志。

这套做法不会替代完整权限系统,但能让本地工具调用先进入可控状态。后续再接数据库、内部 API 或远程服务时,也应该继续沿用“最小权限工具”的思路。

参考资料:

  • Model Context Protocol 文档:https://modelcontextprotocol.io/docs
  • MCP Python SDK:https://github.com/modelcontextprotocol/python-sdk

友情链接

其它