diff --git a/src/Mcp/Tool/Task/CreateTaskTool.php b/src/Mcp/Tool/Task/CreateTaskTool.php index 922e8aa..7919819 100644 --- a/src/Mcp/Tool/Task/CreateTaskTool.php +++ b/src/Mcp/Tool/Task/CreateTaskTool.php @@ -24,7 +24,7 @@ use Symfony\Component\Security\Core\Exception\AccessDeniedException; use function sprintf; -#[McpTool(name: 'create-task', description: 'Create a new task in a project. The task number is auto-generated. Use list-statuses, list-priorities, list-efforts, list-tags, list-groups to discover valid IDs.')] +#[McpTool(name: 'create-task', description: 'Create a new task in a project. The task number is auto-generated. Use list-statuses, list-priorities, list-efforts, list-tags, list-groups to discover valid IDs. The status parameter must reference a status that belongs to the target project\'s workflow — otherwise the call is rejected with a validation error.')] class CreateTaskTool { public function __construct( diff --git a/src/Mcp/Tool/Task/UpdateTaskTool.php b/src/Mcp/Tool/Task/UpdateTaskTool.php index f078cd6..82c0c6b 100644 --- a/src/Mcp/Tool/Task/UpdateTaskTool.php +++ b/src/Mcp/Tool/Task/UpdateTaskTool.php @@ -22,7 +22,7 @@ use Symfony\Component\Security\Core\Exception\AccessDeniedException; use function sprintf; -#[McpTool(name: 'update-task', description: 'Update an existing task. Only provided fields are changed. Use list-statuses, list-priorities, etc. to discover valid IDs.')] +#[McpTool(name: 'update-task', description: 'Update an existing task. Only provided fields are changed. Use list-statuses, list-priorities, etc. to discover valid IDs. The status parameter must reference a status that belongs to the task\'s project workflow — otherwise the call is rejected with a validation error.')] class UpdateTaskTool { public function __construct( diff --git a/src/Mcp/Tool/TaskMeta/ListStatusesTool.php b/src/Mcp/Tool/TaskMeta/ListStatusesTool.php index b516933..db6c8e3 100644 --- a/src/Mcp/Tool/TaskMeta/ListStatusesTool.php +++ b/src/Mcp/Tool/TaskMeta/ListStatusesTool.php @@ -4,33 +4,49 @@ declare(strict_types=1); namespace App\Mcp\Tool\TaskMeta; +use App\Entity\Project; use App\Repository\TaskStatusRepository; +use Doctrine\ORM\EntityManagerInterface; use Mcp\Capability\Attribute\McpTool; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\Security\Core\Exception\AccessDeniedException; -#[McpTool(name: 'list-statuses', description: 'List all task statuses ordered by position. Statuses are global (shared across all projects). Use the returned IDs when creating or updating tasks.')] +#[McpTool( + name: 'list-statuses', + description: 'List task statuses. With projectId, returns only the statuses of that project\'s workflow. Without projectId, returns ALL statuses across workflows (use list-workflows to see how they group).', +)] class ListStatusesTool { public function __construct( private readonly TaskStatusRepository $taskStatusRepository, + private readonly EntityManagerInterface $entityManager, private readonly Security $security, ) {} - public function __invoke(): string + public function __invoke(?int $projectId = null): string { if (!$this->security->isGranted('ROLE_USER')) { throw new AccessDeniedException('Access denied: ROLE_USER required.'); } - $statuses = $this->taskStatusRepository->findBy([], ['position' => 'ASC']); + if (null !== $projectId) { + $project = $this->entityManager->find(Project::class, $projectId); + if (!$project) { + return json_encode(['error' => 'Project not found.']); + } + $statuses = $project->getWorkflow()->getStatuses()->toArray(); + } else { + $statuses = $this->taskStatusRepository->findBy([], ['position' => 'ASC']); + } return json_encode(array_map(fn ($s) => [ - 'id' => $s->getId(), - 'label' => $s->getLabel(), - 'color' => $s->getColor(), - 'position' => $s->getPosition(), - 'isFinal' => $s->getIsFinal(), + 'id' => $s->getId(), + 'label' => $s->getLabel(), + 'color' => $s->getColor(), + 'position' => $s->getPosition(), + 'isFinal' => $s->getIsFinal(), + 'category' => $s->getCategory()->value, + 'workflowId' => $s->getWorkflow()?->getId(), ], $statuses)); } } diff --git a/src/Mcp/Tool/Workflow/ListWorkflowsTool.php b/src/Mcp/Tool/Workflow/ListWorkflowsTool.php new file mode 100644 index 0000000..03701d2 --- /dev/null +++ b/src/Mcp/Tool/Workflow/ListWorkflowsTool.php @@ -0,0 +1,46 @@ +security->isGranted('ROLE_USER')) { + throw new AccessDeniedException('Access denied: ROLE_USER required.'); + } + + $workflows = $this->workflowRepository->findBy([], ['position' => 'ASC']); + + return json_encode(array_map(fn ($w) => [ + 'id' => $w->getId(), + 'name' => $w->getName(), + 'isDefault' => $w->isDefault(), + 'position' => $w->getPosition(), + 'statuses' => array_map(fn ($s) => [ + 'id' => $s->getId(), + 'label' => $s->getLabel(), + 'color' => $s->getColor(), + 'position' => $s->getPosition(), + 'isFinal' => $s->getIsFinal(), + 'category' => $s->getCategory()->value, + ], $w->getStatuses()->toArray()), + ], $workflows)); + } +} diff --git a/src/Mcp/Tool/Workflow/SwitchProjectWorkflowTool.php b/src/Mcp/Tool/Workflow/SwitchProjectWorkflowTool.php new file mode 100644 index 0000000..f0448f7 --- /dev/null +++ b/src/Mcp/Tool/Workflow/SwitchProjectWorkflowTool.php @@ -0,0 +1,65 @@ + $mapping + */ + public function __invoke(int $projectId, int $workflowId, array $mapping): string + { + if (!$this->security->isGranted('ROLE_ADMIN')) { + throw new AccessDeniedException('Access denied: ROLE_ADMIN required.'); + } + + $project = $this->entityManager->find(Project::class, $projectId); + if (!$project) { + return json_encode(['error' => 'Project not found.']); + } + + $fakeRequest = Request::create('', 'POST', [], [], [], [], json_encode([ + 'workflowId' => $workflowId, + 'mapping' => $mapping, + ])); + + try { + $result = $this->processor->process( + $project, + operation: new Post(name: 'switch_workflow'), + uriVariables: ['id' => $projectId], + context: ['request' => $fakeRequest], + ); + } catch (Throwable $e) { + return json_encode(['error' => $e->getMessage()]); + } + + return json_encode([ + 'migratedTaskCount' => $result->migratedTaskCount, + 'projectId' => $result->projectId, + 'workflowId' => $result->workflowId, + ]); + } +}