Skip to main content
Glama
path_resolver.py10.7 kB
""" Path resolution and validation for RBT documents. @REQ: REQ-rbt-mcp-tool @BP: BP-rbt-mcp-tool @TASK: TASK-001-PathResolver """ import os from pathlib import Path from typing import Optional from glob import glob from .models import PathInfo class PathResolver: """ Resolves and validates file paths for RBT documents and general files. Supports: - RBT standard paths: {root}/{project_id}/features/{feature_id}/{doc_type}-{feature_id}.md - TASK paths: {root}/{project_id}/features/{feature_id}/tasks/TASK-{index}-{name}.md - General file paths: {root}/{project_id}/docs/{file_path} - .new.md priority: Prefers .new.md over .md when both exist - TASK partial matching: Resolves TASK-001 to full filename @REQ: REQ-rbt-mcp-tool @BP: BP-rbt-mcp-tool @TASK: TASK-001-PathResolver """ def __init__(self, root_dir: str): """ Initialize PathResolver with root directory. Args: root_dir: Root directory for all documents """ self.root_dir = root_dir def resolve( self, project_id: str, feature_id: Optional[str] = None, doc_type: Optional[str] = None, file_path: Optional[str] = None ) -> PathInfo: """ Resolve document path based on provided parameters. Args: project_id: Project identifier (required) feature_id: Feature identifier (optional, required for RBT docs) doc_type: Document type ('REQ', 'BP', 'TASK') for RBT docs file_path: Relative path for general files or TASK name Returns: PathInfo with resolved path information Raises: ValueError: If invalid parameter combination FileNotFoundError: If resolved file doesn't exist @REQ: REQ-rbt-mcp-tool @BP: BP-rbt-mcp-tool @TASK: TASK-001-PathResolver """ # Validate parameters if doc_type is None and file_path is None: raise ValueError("Either doc_type or file_path must be provided") # Handle RBT document types if doc_type: return self._resolve_rbt_document(project_id, feature_id, doc_type, file_path) # Handle general files return self._resolve_general_file(project_id, file_path) def _resolve_rbt_document( self, project_id: str, feature_id: Optional[str], doc_type: str, file_path: Optional[str] ) -> PathInfo: """ Resolve RBT standard document path. @TASK: TASK-001-PathResolver """ # TASK type has special handling if doc_type == "TASK": return self._resolve_task_document(project_id, feature_id, file_path) # REQ and BP types require feature_id if not feature_id: raise ValueError(f"feature_id is required for doc_type={doc_type}") # Build standard RBT path base_path = Path(self.root_dir) / project_id / "features" / feature_id filename = f"{doc_type}-{feature_id}.md" full_path = base_path / filename # Check for .new.md version first (priority) new_md_path = base_path / f"{doc_type}-{feature_id}.new.md" if os.path.exists(new_md_path): full_path = new_md_path file_exists = True else: # Check if regular .md file exists file_exists = os.path.exists(full_path) return PathInfo( project_id=project_id, feature_id=feature_id, doc_type=doc_type, file_path=str(full_path), is_rbt=True, file_exists=file_exists ) def _resolve_task_document( self, project_id: str, feature_id: Optional[str], file_path: Optional[str] ) -> PathInfo: """ Resolve TASK document within specified feature with partial matching support. Requirements: - feature_id: Can be either: 1. Full feature name (e.g., "rbt-mcp-tool") → searches in that specific feature 2. Task number only (e.g., "001" or "TASK-001") → searches across all features (fuzzy match) - file_path: Optional, specifies TASK identifier with partial matching support Examples: "014" → TASK-014-*.md, "014-CreateDocumentTool" → TASK-014-CreateDocumentTool.md If not provided, uses feature_id as task number Path format: {root}/{project_id}/features/{feature_id}/tasks/TASK-{file_path}.md @TASK: TASK-001-PathResolver """ # feature_id is required if not feature_id: raise ValueError("feature_id is required for TASK doc_type") # Check if feature_id looks like a task number (fuzzy match mode) # Task number pattern: pure digits, or TASK-digits task_number_pattern = feature_id.replace("TASK-", "") is_task_number = task_number_pattern.isdigit() if is_task_number: # Fuzzy match mode: search across all features return self._resolve_task_fuzzy(project_id, task_number_pattern) # Normal mode: feature_id is a feature folder name feature_path = Path(self.root_dir) / project_id / "features" / feature_id # file_path is required to identify specific TASK if not file_path: raise ValueError("file_path is required to specify TASK identifier (e.g., '014' or '014-CreateDocumentTool')") # Build TASK path base_path = feature_path / "tasks" # Try exact match first filename = f"TASK-{file_path}.md" full_path = base_path / filename if os.path.exists(full_path): return PathInfo( project_id=project_id, feature_id=feature_id, doc_type="TASK", file_path=str(full_path), is_rbt=True, file_exists=True ) # Exact match failed, try glob matching for partial IDs (e.g., "006" → TASK-006-*.md) search_pattern = str(base_path / f"TASK-{file_path}-*.md") matches = glob(search_pattern) if len(matches) == 1: # Single match found return PathInfo( project_id=project_id, feature_id=feature_id, doc_type="TASK", file_path=str(matches[0]), is_rbt=True, file_exists=True ) elif len(matches) > 1: raise ValueError( f"Ambiguous TASK identifier '{file_path}'\n" f"Found {len(matches)} matches:\n" + "\n".join(f" - {Path(m).name}" for m in matches) + "\n\nPlease provide full TASK filename to disambiguate." ) # No matches found - return path for new document return PathInfo( project_id=project_id, feature_id=feature_id, doc_type="TASK", file_path=str(full_path), is_rbt=True, file_exists=False ) def _resolve_task_fuzzy(self, project_id: str, task_number: str) -> PathInfo: """ Fuzzy search for TASK document across all features. Note: Fuzzy mode does NOT support creating new documents since we can't determine which feature the new TASK should belong to. Use explicit feature_id for new documents. Args: project_id: Project identifier task_number: Task number (e.g., "001") Returns: PathInfo for the found task Raises: FileNotFoundError: If no matching task found ValueError: If multiple matching tasks found (ambiguous) @TASK: TASK-001-PathResolver """ features_base = Path(self.root_dir) / project_id / "features" if not os.path.exists(features_base): raise FileNotFoundError(f"Features directory not found: {features_base}") # Search pattern: features/*/tasks/TASK-{task_number}-*.md search_pattern = str(features_base / "*" / "tasks" / f"TASK-{task_number}-*.md") matches = glob(search_pattern) if len(matches) == 0: raise FileNotFoundError( f"TASK document not found\n" f"Searched for: TASK-{task_number}-*.md in any feature\n" f"Search base: {features_base}\n" f"Please verify TASK number is correct: {task_number}\n\n" f"Note: To create a new TASK document, please specify the feature_id explicitly." ) if len(matches) > 1: raise ValueError( f"Ambiguous TASK ID '{task_number}'\n" f"Found {len(matches)} matches:\n" + "\n".join(f" - {Path(m).relative_to(features_base)}" for m in matches) + "\n\nPlease specify feature_id to disambiguate." ) # Single match found resolved_path = matches[0] # Extract feature_id from path # Path format: .../features/{feature_id}/tasks/TASK-{task_number}-*.md path_parts = Path(resolved_path).parts features_idx = path_parts.index("features") feature_id = path_parts[features_idx + 1] return PathInfo( project_id=project_id, feature_id=feature_id, doc_type="TASK", file_path=str(resolved_path), is_rbt=True, file_exists=True ) def _resolve_general_file(self, project_id: str, file_path: str) -> PathInfo: """ Resolve general file path (non-RBT). Automatically handles "docs/" prefix in file_path: - "docs/todos/xxx.md" → {root}/{project_id}/docs/todos/xxx.md - "todos/xxx.md" → {root}/{project_id}/docs/todos/xxx.md @TASK: TASK-001-PathResolver """ # Automatically handle "docs/" prefix if file_path.startswith("docs/"): # Remove "docs/" prefix to avoid duplication relative_path = file_path[5:] else: # No "docs/" prefix, use as-is relative_path = file_path # Build general file path (always under docs/) full_path = Path(self.root_dir) / project_id / "docs" / relative_path # Check if file exists (but don't raise error) file_exists = os.path.exists(full_path) return PathInfo( project_id=project_id, feature_id=None, doc_type=None, file_path=str(full_path), is_rbt=False, file_exists=file_exists )

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/leo7nel23/KnowkedgeSmith-MCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server