Server Implementation
An MCP tool checks file properties (existence, ownership, size) and then opens the file in a separate operation — an attacker swaps the file in the gap, defeating the check.
TOCTOU bugs come from doing a check (`os.path.exists`, `os.stat`, `Path.is_file`) on a path, then doing an action (`open`, `read`, `chmod`) on the same path. Between check and use, an attacker can replace the file with a symlink or a different file, and the action runs against the substitute. The fix is to do the action atomically — open the path, then check the open file descriptor's properties.
MCP servers that handle uploads, temporary files, or user-named paths are the most common offenders. The check-then-use pattern is intuitive but unsafe; opening first, checking second is the safe pattern but feels backwards to most developers.
import os |
@server.tool() |
def safe_read(path: str) -> str: |
if not os.path.isfile(path): |
raise ValueError("not a file") |
if os.stat(path).st_size > 1_000_000: |
raise ValueError("too large") |
with open(path) as f: |
return f.read() |
import os |
@server.tool() |
def safe_read(path: str) -> str: |
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW) |
try: |
st = os.fstat(fd) |
if not stat.S_ISREG(st.st_mode): |
raise ValueError("not a regular file") |
if st.st_size > 1_000_000: |
raise ValueError("too large") |
return os.fdopen(fd).read() |
except BaseException: |
os.close(fd) |
raise |
MCPSafe flags Python files where `os.path.isfile(p)` / `os.stat(p)` / `Path(p).is_file()` is followed by `open(p)` / `Path(p).read_text()` against the same variable. JS-side flags `fs.existsSync(p)` followed by `fs.readFileSync(p)`. Atomic alternatives (`os.open` + `os.fstat`) are exempted.
See the full threat catalog for every documented detection.
MCPSafe runs this check — and every other rule in the catalog — on any MCP server you paste in.
Scan now