Source code for klea_utils.mcp.tool_impls.list_files

#!/usr/bin/env python3
"""
File listing implementation for Klea MCP tools.

File: klea_utils/mcp/tool_impls/list_files.py

Copyright 2026 Ankur Sinha
Author: Ankur Sinha <sanjay DOT ankur AT gmail DOT com>
"""

import fnmatch
import logging
import os
from pathlib import Path
from typing import Any

from klea_utils.mcp.errors import PermissionDeniedError
from klea_utils.mcp.tool_impls.permission import check_path_access

logger = logging.getLogger(__name__)


[docs] def list_files( path: str, max_depth: int | None = None, pattern: str = "*", include_files: bool = True, include_directories: bool = True, recursive: bool = False, max_results: int = 100, project_root: str | None = None, ) -> dict[str, Any]: """List files and directories with filtering and metadata. Framework-agnostic implementation shared across Klea MCP servers. Apps wrap this in an MCP tool (see klea_utils.mcp.registry). :param path: Directory path to list. Must be relative to current working directory and cannot contain '..' for security. :param max_depth: Maximum directory depth to traverse. 1 lists the immediate entries inside *path*, 2 also descends one directory deeper, and so on. ``None`` for unlimited. :param pattern: Space separated file patterns to filter based on file type. :param include_files: Whether to include files in results. :param include_directories: Whether to include directories in results. :param recursive: If True, traverse subdirectories recursively. :param max_results: Maximum number of entries to return. :param project_root: Boundary directory for the permission check. Defaults to the current working directory. :returns: dict with files, error, truncated. """ logger.debug( f"Listing files\n" f"{path = }\n" f"{max_depth = }\n" f"{pattern = }\n" f"{include_files = }\n" f"{include_directories = }\n" f"{recursive = }\n" f"{max_results = }" ) the_path = Path(path) truncated = False error = "" files: list[dict[str, Any]] = [] paths: list[Path] = [] try: check_path_access(the_path, project_root) except PermissionDeniedError as exc: logger.warning(f"Permission denied for {path}") return { "files": [], "truncated": False, "error": str(exc), } patterns = list(set(pattern.split())) def _matches(entry: Path) -> bool: return any(fnmatch.fnmatch(entry.name, p) for p in patterns) def _include(entry: Path) -> bool: # Symlinks are always listed: they carry their own `link` type so the # caller can decide how to treat them; the include_* flags only apply # to real files and directories. if entry.is_symlink(): return True is_dir = entry.is_dir() if is_dir: return include_directories return include_files try: if not recursive: with os.scandir(the_path) as it: for entry in it: p = Path(entry.path) if _matches(p) and _include(p): paths.append(p) else: depth_limit = max_depth if max_depth is not None else float("inf") stack: list[tuple[Path, int]] = [(the_path, 1)] while stack: d, depth = stack.pop() if depth > depth_limit: continue with os.scandir(d) as it: entries = list(it) for entry in entries: p = Path(entry.path) if _matches(p) and _include(p): paths.append(p) if ( p.is_dir() and not p.is_symlink() and (max_depth is None or depth < max_depth) ): stack.append((p, depth + 1)) if len(paths) > max_results: truncated = True for f in paths[:max_results]: ftype = "file" if f.is_dir(): ftype = "directory" if f.is_symlink(): ftype = "link" files.append( { "path": str(f), "type": ftype, "modified time": f.stat().st_mtime, "size": f.stat().st_size, } ) except OSError as e: logger.warning(f"Error listing {path}: {e}") error = e.__str__() logger.debug( f"Listed files\n{path = }\n{len(files) = }\n{truncated = }\n{error = }" ) result = {"files": files, "error": error, "truncated": truncated} return result