Server Implementation
A file-reading or file-writing tool joins a user-supplied filename to a base directory without normalizing, letting `../` segments escape the intended scope. Scoped to tool-argument flows; the equivalent bug at the `resources/read` primitive is covered by MCP-212.
Path traversal is the bug where `base_dir + "/" + user_input` produces a path outside `base_dir` because `user_input` contains `../` or an absolute path. The canonical attack reads `/etc/passwd` or `~/.ssh/id_rsa`. On Windows, `..` and drive prefixes like `C:` play the same role.
File-system tools are one of the most common MCP server types — "let the model read my notes folder." Authors remember to scope reads to a base directory but forget that `os.path.join("/notes", user_arg)` is not a security check. An LLM instructed by a malicious note will happily try `../../../etc/shadow`; on misconfigured containers, the file exists and is readable.
BASE = "/srv/notes" |
@server.tool() |
def read_note(filename: str) -> str: |
path = os.path.join(BASE, filename) |
with open(path) as f: |
return f.read() |
from pathlib import Path |
BASE = Path("/srv/notes").resolve() |
@server.tool() |
def read_note(filename: str) -> str: |
# Reject absolute paths and any ".." segment before resolution. |
candidate = (BASE / filename).resolve() |
if BASE not in candidate.parents and candidate != BASE: |
raise ValueError("path outside notes directory") |
return candidate.read_text() |
We flag file I/O where the argument has not passed through a path-normalization + containment check (`resolve()` + `is_relative_to`, `path.resolve()` + `startsWith(base)`, or equivalent). `open(user_arg)` with no preprocessing is always flagged.
See the full threat catalog for every documented detection.
CVEs of the same CWE class. Not MCP-specific, but exemplify the failure mode MCPSafe detects.
MCPSafe runs this check — and every other rule in the catalog — on any MCP server you paste in.
Scan now