Python MCP Server 安全实战:给 AI 工具调用加边界
MCP Server 很容易写出第一个版本,但真正接入 AI 客户端以后,另一个问题会马上出现:模型能调用工具,也可能把错误参数、越界路径或危险命令传给工具。
这篇文章的目标很明确:用 Python 和 FastMCP 写一个更安全的本地 MCP Server。它不追求复杂权限系统,只先做好四件实用的事:
- 限制工具只能访问项目目录
- 只允许读取指定后缀的文件
- 限制单次读取大小
- 记录每次工具调用的审计日志
方法 1:先定义安全边界
不要从“让 AI 能做所有事”开始,而是先写清楚它不能做什么。
本文的示例工具只允许:
- 列出项目内的少量文本类文件
- 读取项目内的安全后缀文件
- 解析项目内的 JSON 配置键名
它不允许:
- 读取
../路径逃逸到项目外 - 读取大文件
- 执行任意 shell 命令
- 返回没有限制的大量内容
这个边界很朴素,但足够覆盖很多本地开发、文档处理和配置检查场景。
方法 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()
这个版本的重点不是工具数量,而是每个工具都有明确边界:
resolve_project_path()防止路径逃逸ALLOWED_SUFFIXES控制可读文件类型MAX_FILE_BYTES控制单次返回内容大小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
- 原文作者:春江暮客
- 原文链接:https://www.bobobk.com/secure-python-mcp-server.html
- 版权声明:本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。