server.ts•15.1 kB
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { exec } from "child_process";
import { promisify } from "util";
import * as fs from "fs/promises";
import * as fsSync from "fs";
import * as path from "path";
import * as os from "os";
import * as dotenv from "dotenv";
import { ServerConfig, ActiveProject } from "./types/index.js";
import { XcodeServerError, ProjectNotFoundError } from "./utils/errors.js";
import { findXcodeProjects, findProjectByName, getProjectInfo } from "./utils/project.js";
// Import our new path management classes
import { PathManager } from "./utils/pathManager.js";
import { SafeFileOperations } from "./utils/safeFileOperations.js";
import { ProjectDirectoryState } from "./utils/projectDirectoryState.js";
// Load environment variables from .env file
dotenv.config();
const execAsync = promisify(exec);
// Tool registration functions
import { registerProjectTools } from "./tools/project/index.js";
import { registerFileTools } from "./tools/file/index.js";
import { registerBuildTools } from "./tools/build/index.js";
import { registerCocoaPodsTools } from "./tools/cocoapods/index.js";
import { registerSPMTools } from "./tools/spm/index.js";
import { registerSimulatorTools } from "./tools/simulator/index.js";
import { registerXcodeTools } from "./tools/xcode/index.js";
export class XcodeServer {
  public server: McpServer;
  public config: ServerConfig = {};
  public activeProject: ActiveProject | null = null;
  public projectFiles: Map<string, string[]> = new Map();
  // Our new path management instances
  public pathManager: PathManager;
  public fileOperations: SafeFileOperations;
  public directoryState: ProjectDirectoryState;
  constructor(config: ServerConfig = {}) {
    // Start with default config
    this.config = { ...this.config, ...config };
    // Use environment variable for projects base directory if not explicitly provided
    if (!this.config.projectsBaseDir && process.env.PROJECTS_BASE_DIR) {
      this.config.projectsBaseDir = process.env.PROJECTS_BASE_DIR;
      console.error(`Using projects base directory from env: ${this.config.projectsBaseDir}`);
    }
    // If still no projects base directory, try some sensible defaults
    if (!this.config.projectsBaseDir) {
      // Common locations for Xcode projects
      const possibleDirs = [
        path.join(os.homedir(), 'Documents'),
        path.join(os.homedir(), 'Projects'),
        path.join(os.homedir(), 'Developer'),
        path.join(os.homedir(), 'Documents/XcodeProjects'),
        path.join(os.homedir(), 'Documents/Projects')
      ];
      // Use the first directory that exists
      for (const dir of possibleDirs) {
        try {
          if (fsSync.existsSync(dir)) {
            this.config.projectsBaseDir = dir;
            console.error(`No projects base directory specified, using default: ${dir}`);
            break;
          }
        } catch (error) {
          // Ignore errors and try the next directory
        }
      }
    }
    // Initialize our path management system
    this.pathManager = new PathManager(this.config);
    this.fileOperations = new SafeFileOperations(this.pathManager);
    this.directoryState = new ProjectDirectoryState(this.pathManager);
    // Create the MCP server
    this.server = new McpServer({
      name: "xcode-server",
      version: "1.0.0",
      description: "An MCP server for Xcode integration"
    }, {
      capabilities: {
        tools: {}
      }
    });
    // Enable debug logging if DEBUG is set
    if (process.env.DEBUG === "true") {
      console.error("Debug mode enabled");
    }
    // Register all tools
    this.registerAllTools();
    this.registerResources();
    // Attempt to auto-detect an active project with more robust handling
    this.detectActiveProject()
      .then(project => {
        if (project) {
          console.error(`Successfully detected active project: ${project.name} (${project.path})`);
        } else {
          console.error("No active project detected automatically. Use set_project_path to set one.");
        }
      })
      .catch((error) => {
        console.error("Error detecting active project:", error.message);
      });
  }
  private registerAllTools() {
    // Register tools from each category
    registerProjectTools(this);
    registerFileTools(this);
    registerBuildTools(this);
    registerCocoaPodsTools(this);
    registerSPMTools(this);
    registerSimulatorTools(this);
    registerXcodeTools(this);
  }
  private registerResources() {
    // Resource to list available Xcode projects.
    this.server.resource(
      "xcode-projects",
      new ResourceTemplate("xcode://projects", { list: undefined }),
      async () => {
        const projects = await findXcodeProjects(this.config.projectsBaseDir);
        return {
          contents: projects.map(project => ({
            uri: `xcode://projects/${encodeURIComponent(project.name)}`,
            text: project.name,
            mimeType: "application/x-xcode-project" as const
          }))
        };
      }
    );
    // Resource to get project details
    this.server.resource(
      "xcode-project",
      new ResourceTemplate("xcode://projects/{name}", { list: undefined }),
      async (uri, { name }) => {
        const decodedName = decodeURIComponent(name as string);
        const project = await findProjectByName(decodedName, this.config.projectsBaseDir);
        if (!project) {
          throw new Error(`Project ${decodedName} not found`);
        }
        return {
          contents: [{
            uri: uri.href,
            text: JSON.stringify(project, null, 2),
            mimeType: "application/json" as const
          }]
        };
      }
    );
  }
  /**
   * Detect an active Xcode project
   * @returns The detected active project or null if none found
   */
  public async detectActiveProject(): Promise<ActiveProject | null> {
    try {
      // Attempt to get the frontmost Xcode project via AppleScript.
      try {
        const { stdout: frontmostProject } = await execAsync(`
          osascript -e '
            tell application "Xcode"
              if it is running then
                set projectFile to path of document 1
                return POSIX path of projectFile
              end if
            end tell
          '
        `);
        if (frontmostProject && frontmostProject.trim()) {
          const projectPath = frontmostProject.trim();
          // Using our new path manager to check boundaries
          if (this.config.projectsBaseDir &&
              !this.pathManager.isPathWithin(this.config.projectsBaseDir, projectPath)) {
            console.error("Active project is outside the configured base directory");
          }
          // Clean up path if it's pointing to project.xcworkspace inside an .xcodeproj
          let cleanedPath = projectPath;
          if (projectPath.endsWith('/project.xcworkspace')) {
            cleanedPath = projectPath.replace('/project.xcworkspace', '');
          }
          const isWorkspace = cleanedPath.endsWith('.xcworkspace');
          let associatedProjectPath;
          if (isWorkspace) {
            try {
              const { findMainProjectInWorkspace } = await import('./utils/project.js');
              associatedProjectPath = await findMainProjectInWorkspace(cleanedPath);
            } catch (error) {
              console.error(`Error finding main project in workspace ${cleanedPath}:`,
                error instanceof Error ? error.message : String(error));
              // Continue without associatedProjectPath
            }
          }
          this.activeProject = {
            path: cleanedPath, // Use the cleaned path
            name: path.basename(cleanedPath, path.extname(cleanedPath)),
            isWorkspace,
            associatedProjectPath
          };
          // Update path manager with active project
          this.pathManager.setActiveProject(cleanedPath);
          // Set the project root as the active directory
          const projectRoot = path.dirname(cleanedPath);
          this.directoryState.setActiveDirectory(projectRoot);
          return this.activeProject;
        }
      } catch (error) {
        // Just log and continue with fallback methods
        console.error("Could not detect active Xcode project via AppleScript:",
          error instanceof Error ? error.message : String(error));
      }
      // Fallback: scan base directory if set.
      if (this.config.projectsBaseDir) {
        try {
          const projects = await findXcodeProjects(this.config.projectsBaseDir);
          if (projects.length > 0) {
            const projectStats = await Promise.all(
              projects.map(async (project) => ({
                project,
                stats: await fs.stat(project.path)
              }))
            );
            const mostRecent = projectStats.sort((a, b) => b.stats.mtime.getTime() - a.stats.mtime.getTime())[0];
            // Clean up path if needed
            let cleanedPath = mostRecent.project.path;
            if (cleanedPath.endsWith('/project.xcworkspace')) {
              cleanedPath = cleanedPath.replace('/project.xcworkspace', '');
              // Update the project object to use the cleaned path
              mostRecent.project.path = cleanedPath;
              mostRecent.project.name = path.basename(cleanedPath, path.extname(cleanedPath));
            }
            this.activeProject = mostRecent.project;
            // Update path manager with active project
            this.pathManager.setActiveProject(cleanedPath);
            // Set the project root as the active directory
            const projectRoot = path.dirname(cleanedPath);
            this.directoryState.setActiveDirectory(projectRoot);
            return this.activeProject;
          }
        } catch (error) {
          console.error("Error scanning projects directory:",
            error instanceof Error ? error.message : String(error));
        }
      }
      // Further fallback: try reading recent projects from Xcode defaults.
      try {
        const { stdout: recentProjects } = await execAsync('defaults read com.apple.dt.Xcode IDERecentWorkspaceDocuments || true');
        if (recentProjects) {
          const projectMatch = recentProjects.match(/= \\"([^"]+)"/);
          if (projectMatch) {
            const recentProject = projectMatch[1];
            // Using our new path manager to check boundaries
            if (this.config.projectsBaseDir &&
                !this.pathManager.isPathWithin(this.config.projectsBaseDir, recentProject)) {
              console.error("Recent project is outside the configured base directory");
            }
            // Clean up path if needed
            let cleanedPath = recentProject;
            if (cleanedPath.endsWith('/project.xcworkspace')) {
              cleanedPath = cleanedPath.replace('/project.xcworkspace', '');
            }
            const isWorkspace = cleanedPath.endsWith('.xcworkspace');
            let associatedProjectPath;
            if (isWorkspace) {
              try {
                const { findMainProjectInWorkspace } = await import('./utils/project.js');
                associatedProjectPath = await findMainProjectInWorkspace(cleanedPath);
              } catch (error) {
                console.error(`Error finding main project in workspace ${cleanedPath}:`,
                  error instanceof Error ? error.message : String(error));
                // Continue without associatedProjectPath
              }
            }
            this.activeProject = {
              path: cleanedPath,
              name: path.basename(cleanedPath, path.extname(cleanedPath)),
              isWorkspace,
              associatedProjectPath
            };
            // Update path manager with active project
            this.pathManager.setActiveProject(cleanedPath);
            // Set the project root as the active directory
            const projectRoot = path.dirname(cleanedPath);
            this.directoryState.setActiveDirectory(projectRoot);
            return this.activeProject;
          }
        }
      } catch (error) {
        console.error("Error reading Xcode defaults:",
          error instanceof Error ? error.message : String(error));
      }
      // If we've tried all methods and found nothing
      console.error("No active Xcode project found. Please open a project in Xcode or set one explicitly.");
      return null;
    } catch (error) {
      console.error("Error detecting active project:",
        error instanceof Error ? error.message : String(error));
      throw error;
    }
  }
  /**
   * Set the active project and update path manager
   */
  public setActiveProject(project: ActiveProject): void {
    // Clean up path if needed
    if (project.path.endsWith('/project.xcworkspace')) {
      const cleanedPath = project.path.replace('/project.xcworkspace', '');
      // Update the project object to use the cleaned path
      project.path = cleanedPath;
      project.name = path.basename(cleanedPath, path.extname(cleanedPath));
    }
    this.activeProject = project;
    this.pathManager.setActiveProject(project.path);
    // Set the project root as the active directory
    const projectRoot = path.dirname(project.path);
    this.directoryState.setActiveDirectory(projectRoot);
  }
  /**
   * Start the server
   */
  public async start() {
    try {
      console.error("Starting Xcode MCP Server...");
      console.error("Node.js version:", process.version);
      console.error("Current working directory:", process.cwd());
      console.error("Projects base directory:", this.config.projectsBaseDir || "Not set");
      // Check if we can access the projects directory
      if (this.config.projectsBaseDir) {
        try {
          await fs.access(this.config.projectsBaseDir);
          console.error("Projects directory exists and is accessible");
        } catch (err) {
          console.error("Warning: Cannot access projects directory:", err instanceof Error ? err.message : String(err));
        }
      }
      // Initialize transport with error handling
      console.error("Initializing StdioServerTransport...");
      const transport = new StdioServerTransport();
      // Connect with detailed logging
      console.error("Connecting to transport...");
      await this.server.connect(transport);
      console.error("Xcode MCP Server started successfully");
    } catch (error) {
      if (error instanceof Error) {
        console.error("Failed to start server:", error.message);
        console.error("Error stack:", error.stack);
        throw new XcodeServerError(`Server initialization failed: ${error.message}`);
      }
      console.error("Unknown error starting server:", error);
      throw new XcodeServerError(`Server initialization failed: ${String(error)}`);
    }
  }
}