Skip to main content
Glama
drewster99

xcode-mcp-server (drewster99)

by drewster99
get_xcode_projects.py11.3 kB
#!/usr/bin/env python3 """get_xcode_projects tool - Find Xcode projects and workspaces""" import os import sys import subprocess import re from xcode_mcp_server.server import mcp from xcode_mcp_server.config_manager import apply_config from xcode_mcp_server.security import ALLOWED_FOLDERS, is_path_allowed from xcode_mcp_server.exceptions import AccessDeniedError, InvalidParameterError from xcode_mcp_server.utils.applescript import show_access_denied_notification, show_error_notification, show_result_notification, show_warning_notification def _get_recent_xcode_projects() -> list[str]: """ Get recently opened Xcode projects by decoding macOS shared file list. Returns: List of absolute paths to recently opened .xcodeproj and .xcworkspace files. Returns empty list if unable to decode recents. """ try: # Get path to Swift decoder script utils_dir = os.path.dirname(os.path.abspath(__file__)) parent_dir = os.path.dirname(utils_dir) swift_script = os.path.join(parent_dir, 'utils', 'decode_xcode_recents.swift') if not os.path.exists(swift_script): print(f"Warning: Swift decoder not found at {swift_script}", file=sys.stderr) return [] # Run Swift script to decode bookmark data result = subprocess.run(['swift', swift_script], capture_output=True, text=True, timeout=5) if result.returncode == 0 and result.stdout.strip(): paths = result.stdout.strip().split('\n') return [p for p in paths if p and os.path.exists(p)] return [] except Exception as e: print(f"Warning: Failed to get recent projects: {e}", file=sys.stderr) return [] def _filter_project_results(paths: list[str], search_paths: list[str] = None, max_depth: int = None, regex_filter: str = None) -> list[str]: """ Filter project paths to remove noise and duplicates. Filters applied: 1. Remove Pods.xcodeproj (CocoaPods dependencies) 2. Remove paths under $HOME/Library (iCloud sync duplicates, system data) 3. Remove paths with .playground in parent directories 4. Remove nested projects (projects inside other .xcodeproj/.xcworkspace folders) 5. Filter by directory depth from search path (if max_depth specified) 6. Filter by regex pattern (if regex_filter specified) 7. Prefer .xcworkspace over .xcodeproj when both exist in same directory Args: paths: List of project paths to filter search_paths: List of base paths being searched (for depth calculation) max_depth: Maximum directory depth from search path (None = no limit) Depth 0 = directly in search path, depth 1 = one level down, etc. regex_filter: Optional regex pattern to filter paths Returns: Filtered list of project paths """ if not paths: return [] home_library = os.path.expanduser("~/Library") filtered = [] # Compile regex if provided regex_pattern = None if regex_filter: try: regex_pattern = re.compile(regex_filter) except re.error as e: print(f"Warning: Invalid regex pattern '{regex_filter}': {e}", file=sys.stderr) for path in paths: # Filter 1: Skip Pods.xcodeproj if os.path.basename(path) == "Pods.xcodeproj": continue # Filter 2: Skip anything under $HOME/Library if path.startswith(home_library): continue # Filter 3: Skip if any parent directory ends with .playground path_parts = path.split('/') has_playground_parent = any(part.endswith('.playground') for part in path_parts[:-1]) if has_playground_parent: continue # Filter 4: Skip nested projects (project inside another .xcodeproj or .xcworkspace) has_nested_parent = any( part.endswith('.xcodeproj') or part.endswith('.xcworkspace') for part in path_parts[:-1] ) if has_nested_parent: continue # Filter 5: Check depth limit if specified if max_depth is not None and search_paths: # Calculate minimum depth from any search path min_depth = None abs_path = os.path.abspath(path) for search_path in search_paths: abs_search = os.path.abspath(search_path) if abs_path.startswith(abs_search): # Calculate depth from this search path # Depth 0 = directly in search path, depth 1 = one level down, etc. rel_path = abs_path[len(abs_search):].lstrip('/') depth = rel_path.count('/') if min_depth is None or depth < min_depth: min_depth = depth # Skip if too deep from all search paths if min_depth is None or min_depth > max_depth: continue # Filter 6: Apply regex filter if specified if regex_pattern and not regex_pattern.search(path): continue filtered.append(path) # Filter 7: Prefer .xcworkspace over .xcodeproj in same directory # Group by directory and base name project_groups = {} for path in filtered: dirname = os.path.dirname(path) basename = os.path.basename(path) # Extract base name without extension if basename.endswith('.xcodeproj'): base = basename[:-10] # Remove .xcodeproj elif basename.endswith('.xcworkspace'): base = basename[:-12] # Remove .xcworkspace else: continue key = (dirname, base) if key not in project_groups: project_groups[key] = [] project_groups[key].append(path) # For each group, prefer .xcworkspace if both exist final_results = [] for (dirname, base), group_paths in project_groups.items(): if len(group_paths) == 1: final_results.append(group_paths[0]) else: # Prefer .xcworkspace over .xcodeproj workspace = [p for p in group_paths if p.endswith('.xcworkspace')] if workspace: final_results.append(workspace[0]) else: # Shouldn't happen, but fall back to first one final_results.append(group_paths[0]) return final_results @mcp.tool() @apply_config def get_xcode_projects( search_path: str = "", include_recents: bool = True, max_search_depth: int = 3, regex_filter: str = None, max_results: int = 10 ) -> str: """ Search for .xcodeproj and .xcworkspace files, optionally including recent projects. If search_path is empty, searches all paths to which this tool has been granted access. Uses `mdfind` (Spotlight indexing) to find files efficiently. Args: search_path: Path to search. If empty, searches all allowed folders. include_recents: If True, include recently opened projects first (default: True) max_search_depth: Maximum directory depth from search path (default: 3) Depth 0 = directly in search path, depth 1 = one level down, etc. regex_filter: Optional regex pattern to filter results max_results: Maximum number of results to return (default: 10) Returns: A newline-separated list of .xcodeproj and .xcworkspace paths. Recent projects appear first if include_recents=True. Returns empty string if none are found. """ # Determine paths to search paths_to_search = [] if not search_path or search_path.strip() == "": # Search all allowed folders paths_to_search = list(ALLOWED_FOLDERS) else: # Search specific path project_path = search_path.strip() # Security check if not is_path_allowed(project_path): show_access_denied_notification(f"Access denied: {project_path}") raise AccessDeniedError(f"Access to path '{project_path}' is not allowed. Set XCODEMCP_ALLOWED_FOLDERS environment variable.") # Check if the path exists if not os.path.exists(project_path): show_error_notification(f"Path not found: {project_path}") raise InvalidParameterError(f"Project path does not exist: {project_path}") paths_to_search = [project_path] # Search for projects in all paths all_results = [] for path in paths_to_search: try: # Use mdfind to search for Xcode projects mdfindResult = subprocess.run(['mdfind', '-onlyin', path, 'kMDItemFSName == "*.xcodeproj" || kMDItemFSName == "*.xcworkspace"'], capture_output=True, text=True, check=True) result = mdfindResult.stdout.strip() if result: all_results.extend(result.split('\n')) except Exception as e: show_warning_notification(f"mdfind failed for {os.path.basename(path)}", str(e)) print(f"Warning: Error searching in {path}: {str(e)}", file=sys.stderr) continue # Get recent projects if requested recent_projects = [] if include_recents: recent_projects = _get_recent_xcode_projects() # Filter recents with same criteria recent_projects = _filter_project_results( recent_projects, search_paths=paths_to_search, max_depth=max_search_depth, regex_filter=regex_filter ) # Filter mdfind results filtered_results = _filter_project_results( all_results, search_paths=paths_to_search, max_depth=max_search_depth, regex_filter=regex_filter ) # Combine recents (first) + mdfind results, removing duplicates # Use dict to preserve order while removing duplicates combined = {} for path in recent_projects: combined[path] = True for path in filtered_results: combined[path] = True unique_results = list(combined.keys()) # Apply max_results limit if max_results and max_results > 0: unique_results = unique_results[:max_results] # Show result notification if unique_results: count = len(unique_results) # Get first 3 project names for notification sample_names = [os.path.basename(p) for p in unique_results[:3]] if count <= 3: details = "\n".join(f"• {name}" for name in sample_names) else: details = "\n".join(f"• {name}" for name in sample_names) + f"\n• +{count - 3} more" # Add note about recents if included note = f"Found {count} project{'s' if count != 1 else ''}" if include_recents and recent_projects: note += f" ({len(recent_projects)} recent)" show_result_notification(note, details) else: show_result_notification("No projects found") result = '\n'.join(unique_results) if unique_results else "" if result: result += "\n\nTo build a project, use `get_project_schemes` to see available build schemes, then call `build_project`." return result

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/drewster99/xcode-mcp-server'

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