diff --git a/src/McpServer/McpConfig.php b/src/McpServer/McpConfig.php index ff352a4..949f986 100644 --- a/src/McpServer/McpConfig.php +++ b/src/McpServer/McpConfig.php @@ -37,6 +37,9 @@ final class McpConfig extends InjectableConfig 'git_operations' => [ 'enable' => true, ], + 'project_operations' => [ + 'enable' => true, + ], ]; public function getDocumentNameFormat(string $path, string $description, string $tags): string @@ -97,4 +100,9 @@ public function isGitOperationsEnabled(): bool { return $this->config['git_operations']['enable'] ?? true; } + + public function isProjectOperationsEnabled(): bool + { + return $this->config['project_operations']['enable'] ?? true; + } } diff --git a/src/McpServer/McpServerBootloader.php b/src/McpServer/McpServerBootloader.php index a95dbe8..7a4532e 100644 --- a/src/McpServer/McpServerBootloader.php +++ b/src/McpServer/McpServerBootloader.php @@ -32,6 +32,8 @@ use Butschster\ContextGenerator\McpServer\Action\Tools\Prompts\GetPromptToolAction; use Butschster\ContextGenerator\McpServer\Action\Tools\Prompts\ListPromptsToolAction; use Butschster\ContextGenerator\McpServer\Console\MCPServerCommand; +use Butschster\ContextGenerator\McpServer\Projects\Actions\ProjectsListToolAction; +use Butschster\ContextGenerator\McpServer\Projects\Actions\ProjectSwitchToolAction; use Butschster\ContextGenerator\McpServer\Projects\McpProjectsBootloader; use Butschster\ContextGenerator\McpServer\ProjectService\ProjectServiceInterface; use Butschster\ContextGenerator\McpServer\Prompt\McpPromptBootloader; @@ -98,6 +100,9 @@ public function init(EnvironmentInterface $env): void 'git_operations' => [ 'enable' => (bool) $env->get('MCP_GIT_OPERATIONS', !$isCommonProject), ], + 'project_operations' => [ + 'enable' => (bool) $env->get('MCP_PROJECT_OPERATIONS', true), + ], ], ); } @@ -189,6 +194,7 @@ private function actions(McpConfig $config): array FetchLibraryDocsAction::class, ]; } + if ($config->isFileOperationsEnabled()) { $actions = [ ...$actions, @@ -209,6 +215,14 @@ private function actions(McpConfig $config): array } } + if ($config->isProjectOperationsEnabled()) { + $actions = [ + ...$actions, + ProjectsListToolAction::class, + ProjectSwitchToolAction::class, + ]; + } + if ($config->isGitOperationsEnabled()) { $actions[] = GitStatusAction::class; $actions[] = GitAddAction::class; diff --git a/src/McpServer/Projects/Actions/Dto/AliasResolutionResponse.php b/src/McpServer/Projects/Actions/Dto/AliasResolutionResponse.php new file mode 100644 index 0000000..ba8c359 --- /dev/null +++ b/src/McpServer/Projects/Actions/Dto/AliasResolutionResponse.php @@ -0,0 +1,24 @@ + $this->originalAlias, + 'resolved_path' => $this->resolvedPath, + ]; + } +} diff --git a/src/McpServer/Projects/Actions/Dto/CurrentProjectResponse.php b/src/McpServer/Projects/Actions/Dto/CurrentProjectResponse.php new file mode 100644 index 0000000..561c3da --- /dev/null +++ b/src/McpServer/Projects/Actions/Dto/CurrentProjectResponse.php @@ -0,0 +1,31 @@ + $this->path, + 'config_file' => $this->configFile, + 'env_file' => $this->envFile, + 'aliases' => $this->aliases, + ]; + } +} diff --git a/src/McpServer/Projects/Actions/Dto/ProjectInfoResponse.php b/src/McpServer/Projects/Actions/Dto/ProjectInfoResponse.php new file mode 100644 index 0000000..01e0528 --- /dev/null +++ b/src/McpServer/Projects/Actions/Dto/ProjectInfoResponse.php @@ -0,0 +1,35 @@ + $this->path, + 'config_file' => $this->configFile, + 'env_file' => $this->envFile, + 'added_at' => $this->addedAt, + 'aliases' => $this->aliases, + 'is_current' => $this->isCurrent, + ]; + } +} diff --git a/src/McpServer/Projects/Actions/Dto/ProjectListRequest.php b/src/McpServer/Projects/Actions/Dto/ProjectListRequest.php new file mode 100644 index 0000000..bd7a377 --- /dev/null +++ b/src/McpServer/Projects/Actions/Dto/ProjectListRequest.php @@ -0,0 +1,12 @@ + $this->success, + 'message' => $this->message, + 'current_project' => $this->currentProject, + 'resolved_from_alias' => $this->resolvedFromAlias, + ], static fn($value) => $value !== null); + } +} diff --git a/src/McpServer/Projects/Actions/Dto/ProjectsListResponse.php b/src/McpServer/Projects/Actions/Dto/ProjectsListResponse.php new file mode 100644 index 0000000..4048cea --- /dev/null +++ b/src/McpServer/Projects/Actions/Dto/ProjectsListResponse.php @@ -0,0 +1,31 @@ + $this->projects, + 'current_project' => $this->currentProject, + 'total_projects' => $this->totalProjects, + 'message' => $this->message, + ]; + } +} diff --git a/src/McpServer/Projects/Actions/ProjectSwitchToolAction.php b/src/McpServer/Projects/Actions/ProjectSwitchToolAction.php new file mode 100644 index 0000000..5227ffd --- /dev/null +++ b/src/McpServer/Projects/Actions/ProjectSwitchToolAction.php @@ -0,0 +1,153 @@ +logger->info('Processing project-switch tool', [ + 'pathOrAlias' => $request->alias, + ]); + + try { + $pathOrAlias = $request->alias; + + if (empty($pathOrAlias)) { + return new CallToolResult([ + new TextContent(text: 'Error: Missing pathOrAlias parameter'), + ], isError: true); + } + + // Handle using an alias as the path + $resolvedPath = $this->projectService->resolvePathOrAlias($pathOrAlias); + $wasAlias = $resolvedPath !== $pathOrAlias; + + // Normalize path to absolute path + $projectPath = $this->normalizePath($resolvedPath); + + // Check if the project exists in our registry + $projects = $this->projectService->getProjects(); + if (!isset($projects[$projectPath])) { + $availableProjects = \array_keys($projects); + $availableAliases = \array_keys($this->projectService->getAliases()); + + $suggestions = []; + if (!empty($availableProjects)) { + $suggestions[] = 'Available project paths: ' . \implode(', ', $availableProjects); + } + if (!empty($availableAliases)) { + $suggestions[] = 'Available aliases: ' . \implode(', ', $availableAliases); + } + + return new CallToolResult([ + new TextContent( + text: \sprintf( + "Error: Project '%s' is not registered.\n%s", + $projectPath, + \implode("\n", $suggestions), + ), + ), + ], isError: true); + } + + // Try to switch to this project + if ($this->projectService->switchToProject($projectPath)) { + $currentProject = $this->projectService->getCurrentProject(); + $aliases = $this->projectService->getAliasesForPath($projectPath); + + $currentProjectResponse = new CurrentProjectResponse( + path: $currentProject->path, + configFile: $currentProject->hasConfigFile() ? $currentProject->getConfigFile() : null, + envFile: $currentProject->hasEnvFile() ? $currentProject->getEnvFile() : null, + aliases: $aliases, + ); + + $aliasResolution = null; + if ($wasAlias) { + $aliasResolution = new AliasResolutionResponse( + originalAlias: $pathOrAlias, + resolvedPath: $projectPath, + ); + } + + $response = new ProjectSwitchResponse( + success: true, + message: \sprintf('Successfully switched to project: %s', $projectPath), + currentProject: $currentProjectResponse, + resolvedFromAlias: $aliasResolution, + ); + + return new CallToolResult([ + new TextContent(text: \json_encode($response, JSON_PRETTY_PRINT)), + ]); + } + + $response = new ProjectSwitchResponse( + success: false, + message: \sprintf("Failed to switch to project '%s'", $projectPath), + ); + + return new CallToolResult([ + new TextContent(text: \json_encode($response, JSON_PRETTY_PRINT)), + ], isError: true); + } catch (\Throwable $e) { + $this->logger->error('Error switching project', [ + 'pathOrAlias' => $request->alias, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + return new CallToolResult([ + new TextContent(text: 'Error: ' . $e->getMessage()), + ], isError: true); + } + } + + /** + * Normalize a path to an absolute path + */ + private function normalizePath(string $path): string + { + // Handle special case for current directory + if ($path === '.') { + return (string) FSPath::cwd(); + } + + $pathObj = FSPath::create($path); + + // If path is relative, make it absolute from the current directory + if ($pathObj->isRelative()) { + $pathObj = $pathObj->absolute(); + } + + return $pathObj->toString(); + } +} diff --git a/src/McpServer/Projects/Actions/ProjectsListToolAction.php b/src/McpServer/Projects/Actions/ProjectsListToolAction.php new file mode 100644 index 0000000..f0326bc --- /dev/null +++ b/src/McpServer/Projects/Actions/ProjectsListToolAction.php @@ -0,0 +1,108 @@ +logger->info('Processing projects-list tool'); + + try { + $projects = $this->projectService->getProjects(); + $aliases = $this->projectService->getAliases(); + $currentProject = $this->projectService->getCurrentProject(); + + if (empty($projects)) { + $response = new ProjectsListResponse( + projects: [], + currentProject: null, + totalProjects: 0, + message: 'No projects registered. Use project:add command to add projects.', + ); + + return new CallToolResult([ + new TextContent(text: \json_encode($response, JSON_PRETTY_PRINT)), + ]); + } + + // Create inverse alias map for quick lookups + $pathToAliases = []; + foreach ($aliases as $alias => $path) { + if (!isset($pathToAliases[$path])) { + $pathToAliases[$path] = []; + } + $pathToAliases[$path][] = $alias; + } + + // Build project info responses + $projectInfos = []; + foreach ($projects as $path => $info) { + $projectInfos[] = new ProjectInfoResponse( + path: $path, + configFile: $info->configFile, + envFile: $info->envFile, + addedAt: $info->addedAt, + aliases: $pathToAliases[$path] ?? [], + isCurrent: $currentProject && $currentProject->path === $path, + ); + } + + // Build current project response + $currentProjectResponse = null; + if ($currentProject !== null) { + $currentProjectResponse = new CurrentProjectResponse( + path: $currentProject->path, + configFile: $currentProject->hasConfigFile() ? $currentProject->getConfigFile() : null, + envFile: $currentProject->hasEnvFile() ? $currentProject->getEnvFile() : null, + aliases: $this->projectService->getAliasesForPath($currentProject->path), + ); + } + + $response = new ProjectsListResponse( + projects: $projectInfos, + currentProject: $currentProjectResponse, + totalProjects: \count($projects), + ); + + return new CallToolResult([ + new TextContent(text: \json_encode($response, JSON_PRETTY_PRINT)), + ]); + } catch (\Throwable $e) { + $this->logger->error('Error listing projects', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + return new CallToolResult([ + new TextContent(text: 'Error: ' . $e->getMessage()), + ], isError: true); + } + } +}