PrefectHQ/fastmcp · error · StateFileError

CLI state must not be a symbolic link: {path.name}

Error message

CLI state must not be a symbolic link: {path.name}

What it means

StateFileError raised by read_state when an existing state file is a symbolic link. Like the lock-file check, this blocks redirecting reads (and secret re-permissioning) to arbitrary paths — a tamper vector — so the CLI rejects symlinked state outright.

Source

Thrown at fastmcp_slim/fastmcp/cli/deploy/state.py:155

            import fcntl

            with suppress(OSError):
                fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
        with suppress(OSError):
            lock_file.close()


def read_state(
    path: Path,
    model: type[ModelT],
    *,
    secret: bool = False,
) -> ModelT | None:
    """Read and validate a versioned JSON state file."""
    if not path.exists():
        return None
    if path.is_symlink():
        raise StateFileError(f"CLI state must not be a symbolic link: {path.name}")

    if secret:
        _restrict_access(path.parent, directory=True)
        _restrict_access(path)

    try:
        return model.model_validate_json(path.read_text(encoding="utf-8"))
    except (ValidationError, ValueError):
        raise StateFileError(f"CLI state is invalid: {path.name}") from None
    except OSError as exc:
        raise StateFileError(f"Could not read CLI state: {path.name}") from exc


def write_state(path: Path, data: dict[str, Any]) -> None:
    """Write JSON through a restricted temporary file and atomic replacement."""
    _prepare_directory(path.parent)
    payload = (json.dumps(data, indent=2, sort_keys=True) + "\n").encode()
    descriptor: int | None = None

View on GitHub (pinned to 1f02114297)

Solutions

  1. Remove the symlink and replace it with a real file (rm the link, then re-run the CLI to regenerate state)
  2. Ensure the state directory is user-only writable (0o700) to prevent tampering
  3. Exclude the state path from dotfile/sync tooling that creates symlinks

Example fix

// before
$ ls -l ~/.fastmcp/state/session.json
session.json -> /mnt/shared/session.json
// after
$ rm ~/.fastmcp/state/session.json
$ fastmcp login   # regenerates a real, restricted file
Defensive patterns

Strategy: validation

Validate before calling

from pathlib import Path

def assert_real_file(path: Path) -> None:
    if path.is_symlink():
        raise RuntimeError(f"{path} is a symlink; replace with a real file")
    if path.exists() and not path.is_file():
        raise RuntimeError(f"{path} is not a regular file")

Type guard

def is_regular_state_file(path: Path) -> bool:
    return path.exists() and not path.is_symlink() and path.is_file()

Try / catch

try:
    state = load(state_path)
except StateFileError as exc:
    if "symbolic link" in str(exc):
        state_path.unlink()
        state = load(state_path)   # regenerates
    else:
        raise

Prevention

When it happens

Trigger: Calling read_state (via load) on a state path where path.exists() is true but path.is_symlink() is also true — e.g. the auth/session state file was replaced by a symlink by a sync tool or attacker.

Common situations: Dotfile managers or git repos symlinking state files; Dropbox/OneDrive restoring a link; shared multi-user directory tampering; a user manually linking state between machines.

Related errors


AI-assisted analysis of PrefectHQ/fastmcp@1f02114297 (2026-08-29). Data as JSON: /api/errors/8e1d5637aae2609e. Report an issue: GitHub.