# Outils MCP — Module Absences + trous CRUD — Plan d'implémentation > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Exposer le module Absences (requests/policies/balances) et combler les trous CRUD existants (projets, groupes, métadonnées de tâches, clients, users) via de nouveaux outils MCP, sans contourner la logique métier des soldes. **Architecture:** Chaque outil = une classe avec `#[McpTool]`, auto-découverte par le scan `src/` de `config/packages/mcp.yaml`. Les outils d'absence répliquent l'orchestration des Processors en réutilisant les services `AbsenceDayCalculator` et `AbsenceBalanceService` (et non les Processors, liés à `Security::getUser()`), avec un `userId` explicite pour agir au nom d'un employé. Sérialisation centralisée dans `App\Mcp\Tool\Serializer`. **Tech Stack:** PHP 8.4, Symfony 8, Doctrine ORM, PHPUnit (`KernelTestCase` + `Security` mocké), serveur MCP `mcp` bundle. --- ## Référence : conventions d'un outil MCP Tout outil suit ce squelette (vu dans `src/Mcp/Tool/Task/CreateTaskTool.php`) : ```php ; use Mcp\Capability\Attribute\McpTool; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\Security\Core\Exception\AccessDeniedException; // + repos/services/entities #[McpTool(name: '', description: '')] class Tool { public function __construct(/* deps injectées */) {} public function __invoke(/* params */): string { if (!$this->security->isGranted('')) { throw new AccessDeniedException('Access denied: required.'); } // ... logique return json_encode(/* tableau */); } } ``` Règles : `InvalidArgumentException` (avec `use function sprintf;`) quand un ID est introuvable ; helpers de parsing d'enum (voir Task 4) ; `json_encode` en sortie. --- ## JALON 1 — Module Absences ### Task 1 : Helpers de sérialisation Absences **Files:** - Modify: `src/Mcp/Tool/Serializer.php` - [ ] **Step 1 : Ajouter les imports d'entités absence** En tête de `Serializer.php`, ajouter aux `use` existants : ```php use App\Entity\AbsenceBalance; use App\Entity\AbsencePolicy; use App\Entity\AbsenceRequest; use App\Entity\Client; ``` - [ ] **Step 2 : Ajouter les méthodes de sérialisation** Ajouter ces méthodes statiques dans la classe `Serializer` (avant l'accolade de fin) : ```php /** * @return array */ public static function absenceRequest(AbsenceRequest $r): array { return [ 'id' => $r->getId(), 'user' => self::user($r->getUser()), 'type' => $r->getType()?->value, 'typeLabel' => $r->getType()?->label(), 'startDate' => $r->getStartDate()?->format('Y-m-d'), 'endDate' => $r->getEndDate()?->format('Y-m-d'), 'startHalfDay' => $r->getStartHalfDay()?->value, 'endHalfDay' => $r->getEndHalfDay()?->value, 'countedDays' => $r->getCountedDays(), 'reason' => $r->getReason(), 'status' => $r->getStatus()->value, 'statusLabel' => $r->getStatus()->label(), 'rejectionReason' => $r->getRejectionReason(), 'justificationFileName' => $r->getJustificationFileName(), 'createdAt' => $r->getCreatedAt()?->format('c'), 'reviewedAt' => $r->getReviewedAt()?->format('c'), 'reviewedBy' => self::user($r->getReviewedBy()), ]; } /** * @return array */ public static function absencePolicy(AbsencePolicy $p): array { return [ 'id' => $p->getId(), 'type' => $p->getType()->value, 'typeLabel' => $p->getType()->label(), 'daysPerYear' => $p->getDaysPerYear(), 'daysPerEvent' => $p->getDaysPerEvent(), 'justificationRequired'=> $p->isJustificationRequired(), 'noticeDays' => $p->getNoticeDays(), 'countWorkingDaysOnly' => $p->isCountWorkingDaysOnly(), 'active' => $p->isActive(), ]; } /** * @return array */ public static function absenceBalance(AbsenceBalance $b): array { return [ 'id' => $b->getId(), 'user' => self::user($b->getUser()), 'type' => $b->getType()->value, 'typeLabel' => $b->getType()->label(), 'period' => $b->getPeriod(), 'acquired' => $b->getAcquired(), 'acquiring' => $b->getAcquiring(), 'taken' => $b->getTaken(), 'pending' => $b->getPending(), 'acquiredTotal' => $b->getAcquiredTotal(), 'available' => $b->getAvailable(), ]; } /** * @return array */ public static function client(Client $c): array { return [ 'id' => $c->getId(), 'name' => $c->getName(), 'email' => $c->getEmail(), 'phone' => $c->getPhone(), 'street' => $c->getStreet(), 'city' => $c->getCity(), 'postalCode' => $c->getPostalCode(), ]; } /** * @return array */ public static function userFull(User $u): array { return [ 'id' => $u->getId(), 'username' => $u->getUsername(), 'roles' => $u->getRoles(), 'isEmployee' => $u->isEmployee(), 'hireDate' => $u->getHireDate()?->format('Y-m-d'), 'endDate' => $u->getEndDate()?->format('Y-m-d'), 'contractType' => $u->getContractType()?->value, 'workTimeRatio' => $u->getWorkTimeRatio(), 'annualLeaveDays' => $u->getAnnualLeaveDays(), 'referencePeriodStart' => $u->getReferencePeriodStart(), 'initialLeaveBalance' => $u->getInitialLeaveBalance(), 'familySituation' => $u->getFamilySituation()?->value, 'nbChildren' => $u->getNbChildren(), ]; } ``` - [ ] **Step 3 : Vérifier que PHP ne casse pas (lint)** Run: `docker exec php-lesstime-fpm php -l src/Mcp/Tool/Serializer.php` Expected: `No syntax errors detected` - [ ] **Step 4 : Commit** ```bash git add src/Mcp/Tool/Serializer.php git commit -m "feat(mcp) : helpers de sérialisation pour absences/client/user" ``` --- ### Task 2 : Outils de policies — `list-absence-policies`, `update-absence-policy` **Files:** - Create: `src/Mcp/Tool/Absence/ListAbsencePoliciesTool.php` - Create: `src/Mcp/Tool/Absence/UpdateAbsencePolicyTool.php` - [ ] **Step 1 : Créer `ListAbsencePoliciesTool`** ```php security->isGranted('ROLE_USER')) { throw new AccessDeniedException('Access denied: ROLE_USER required.'); } $policies = $this->policyRepository->findBy([], ['type' => 'ASC']); return json_encode(array_map(Serializer::absencePolicy(...), $policies)); } } ``` - [ ] **Step 2 : Créer `UpdateAbsencePolicyTool`** ```php security->isGranted('ROLE_ADMIN')) { throw new AccessDeniedException('Access denied: ROLE_ADMIN required.'); } $policy = $this->policyRepository->find($id); if (null === $policy) { throw new InvalidArgumentException(sprintf('AbsencePolicy with ID %d not found.', $id)); } if (null !== $daysPerYear) { $policy->setDaysPerYear($daysPerYear); } if (null !== $daysPerEvent) { $policy->setDaysPerEvent($daysPerEvent); } if (null !== $justificationRequired) { $policy->setJustificationRequired($justificationRequired); } if (null !== $noticeDays) { $policy->setNoticeDays($noticeDays); } if (null !== $countWorkingDaysOnly) { $policy->setCountWorkingDaysOnly($countWorkingDaysOnly); } if (null !== $active) { $policy->setActive($active); } $this->entityManager->flush(); return json_encode(Serializer::absencePolicy($policy)); } } ``` - [ ] **Step 3 : Lint** Run: `docker exec php-lesstime-fpm php -l src/Mcp/Tool/Absence/ListAbsencePoliciesTool.php && docker exec php-lesstime-fpm php -l src/Mcp/Tool/Absence/UpdateAbsencePolicyTool.php` Expected: `No syntax errors detected` ×2 - [ ] **Step 4 : Vérifier la découverte MCP** Run: `docker exec php-lesstime-fpm php bin/console debug:container --tag=mcp.tool 2>/dev/null | grep -i absence || docker exec php-lesstime-fpm php bin/console cache:clear` Expected : pas d'erreur de cache (la découverte se fait au runtime ; l'absence d'erreur de compilation suffit ici). - [ ] **Step 5 : Commit** ```bash git add src/Mcp/Tool/Absence/ListAbsencePoliciesTool.php src/Mcp/Tool/Absence/UpdateAbsencePolicyTool.php git commit -m "feat(mcp) : outils list/update absence-policy" ``` --- ### Task 3 : Outils de soldes — `list-absence-balances`, `update-absence-balance` **Files:** - Create: `src/Mcp/Tool/Absence/ListAbsenceBalancesTool.php` - Create: `src/Mcp/Tool/Absence/UpdateAbsenceBalanceTool.php` - [ ] **Step 1 : Créer `ListAbsenceBalancesTool`** ```php security->isGranted('ROLE_USER')) { throw new AccessDeniedException('Access denied: ROLE_USER required.'); } $criteria = []; if (null !== $userId) { $user = $this->userRepository->find($userId); if (null === $user) { throw new InvalidArgumentException(sprintf('User with ID %d not found.', $userId)); } $criteria['user'] = $user; } if (null !== $type) { $criteria['type'] = AbsenceType::tryFrom($type) ?? throw new InvalidArgumentException(sprintf('Unknown absence type "%s".', $type)); } if (null !== $period) { $criteria['period'] = $period; } $balances = $this->balanceRepository->findBy($criteria, ['period' => 'DESC']); return json_encode(array_map(Serializer::absenceBalance(...), $balances)); } } ``` - [ ] **Step 2 : Créer `UpdateAbsenceBalanceTool`** ```php security->isGranted('ROLE_ADMIN')) { throw new AccessDeniedException('Access denied: ROLE_ADMIN required.'); } $balance = $this->balanceRepository->find($id); if (null === $balance) { throw new InvalidArgumentException(sprintf('AbsenceBalance with ID %d not found.', $id)); } if (null !== $acquired) { $balance->setAcquired($acquired); } if (null !== $acquiring) { $balance->setAcquiring($acquiring); } if (null !== $taken) { $balance->setTaken($taken); } $this->entityManager->flush(); return json_encode(Serializer::absenceBalance($balance)); } } ``` - [ ] **Step 3 : Lint** Run: `docker exec php-lesstime-fpm php -l src/Mcp/Tool/Absence/ListAbsenceBalancesTool.php && docker exec php-lesstime-fpm php -l src/Mcp/Tool/Absence/UpdateAbsenceBalanceTool.php` Expected: `No syntax errors detected` ×2 - [ ] **Step 4 : Commit** ```bash git add src/Mcp/Tool/Absence/ListAbsenceBalancesTool.php src/Mcp/Tool/Absence/UpdateAbsenceBalanceTool.php git commit -m "feat(mcp) : outils list/update absence-balance" ``` --- ### Task 4 : Outils de lecture des demandes — repo filtré + `list/get-absence-request` **Files:** - Modify: `src/Repository/AbsenceRequestRepository.php` - Create: `src/Mcp/Tool/Absence/ListAbsenceRequestsTool.php` - Create: `src/Mcp/Tool/Absence/GetAbsenceRequestTool.php` - [ ] **Step 1 : Ajouter une méthode de filtrage au repo** Dans `AbsenceRequestRepository`, ajouter les imports si absents (`use App\Enum\AbsenceType;` déjà présent ? sinon ajouter) et la méthode : ```php /** * @return AbsenceRequest[] */ public function findFiltered( ?User $user = null, ?AbsenceStatus $status = null, ?\App\Enum\AbsenceType $type = null, ?DateTimeInterface $from = null, ?DateTimeInterface $to = null, ): array { $qb = $this->createQueryBuilder('a')->orderBy('a.startDate', 'DESC'); if (null !== $user) { $qb->andWhere('a.user = :user')->setParameter('user', $user); } if (null !== $status) { $qb->andWhere('a.status = :status')->setParameter('status', $status); } if (null !== $type) { $qb->andWhere('a.type = :type')->setParameter('type', $type); } if (null !== $from) { $qb->andWhere('a.endDate >= :from')->setParameter('from', $from->format('Y-m-d')); } if (null !== $to) { $qb->andWhere('a.startDate <= :to')->setParameter('to', $to->format('Y-m-d')); } return $qb->getQuery()->getResult(); } ``` > Note : `AbsenceStatus` est déjà importé dans ce repo. Ajouter `use App\Enum\AbsenceType;` en tête s'il n'y est pas (sinon le FQCN inline ci-dessus suffit, mais préférer l'import et remplacer `\App\Enum\AbsenceType` par `AbsenceType`). - [ ] **Step 2 : Créer `ListAbsenceRequestsTool`** ```php security->isGranted('ROLE_USER')) { throw new AccessDeniedException('Access denied: ROLE_USER required.'); } $user = null; if (null !== $userId) { $user = $this->userRepository->find($userId) ?? throw new InvalidArgumentException(sprintf('User with ID %d not found.', $userId)); } $statusEnum = null; if (null !== $status) { $statusEnum = AbsenceStatus::tryFrom($status) ?? throw new InvalidArgumentException(sprintf('Unknown status "%s".', $status)); } $typeEnum = null; if (null !== $type) { $typeEnum = AbsenceType::tryFrom($type) ?? throw new InvalidArgumentException(sprintf('Unknown absence type "%s".', $type)); } $requests = $this->requestRepository->findFiltered( $user, $statusEnum, $typeEnum, null !== $from ? new DateTimeImmutable($from) : null, null !== $to ? new DateTimeImmutable($to) : null, ); return json_encode(array_map(Serializer::absenceRequest(...), $requests)); } } ``` - [ ] **Step 3 : Créer `GetAbsenceRequestTool`** ```php security->isGranted('ROLE_USER')) { throw new AccessDeniedException('Access denied: ROLE_USER required.'); } $request = $this->requestRepository->find($id); if (null === $request) { throw new InvalidArgumentException(sprintf('AbsenceRequest with ID %d not found.', $id)); } return json_encode(Serializer::absenceRequest($request)); } } ``` - [ ] **Step 4 : Lint les 3 fichiers** Run: `for f in src/Repository/AbsenceRequestRepository.php src/Mcp/Tool/Absence/ListAbsenceRequestsTool.php src/Mcp/Tool/Absence/GetAbsenceRequestTool.php; do docker exec php-lesstime-fpm php -l $f; done` Expected: `No syntax errors detected` ×3 - [ ] **Step 5 : Commit** ```bash git add src/Repository/AbsenceRequestRepository.php src/Mcp/Tool/Absence/ListAbsenceRequestsTool.php src/Mcp/Tool/Absence/GetAbsenceRequestTool.php git commit -m "feat(mcp) : outils list/get absence-request + repo findFiltered" ``` --- ### Task 5 : `create-absence-request` (réplique `AbsenceRequestProcessor`) **Files:** - Create: `src/Mcp/Tool/Absence/CreateAbsenceRequestTool.php` - Create: `tests/Functional/Mcp/AbsenceRequestLifecycleTest.php` - [ ] **Step 1 : Écrire le test du cycle de vie (échoue d'abord) — création** Ce test crée son propre employé et sa policy (auto-suffisant), puis vérifie la création + la réservation de solde. Il sera complété aux Tasks 6 et 7. ```php em = self::getContainer()->get(EntityManagerInterface::class); // Employé dédié au test. $this->employee = new User(); $this->employee->setUsername('mcp-test-employee-'.uniqid()); $this->employee->setPassword('x'); $this->employee->setRoles(['ROLE_USER']); $this->employee->setIsEmployee(true); $this->employee->setReferencePeriodStart('06-01'); $this->em->persist($this->employee); $this->admin = new User(); $this->admin->setUsername('mcp-test-admin-'.uniqid()); $this->admin->setPassword('x'); $this->admin->setRoles(['ROLE_ADMIN']); $this->em->persist($this->admin); // Policy CP active (créée si absente — findOneByType peut déjà exister via fixtures). $policy = $this->em->getRepository(AbsencePolicy::class)->findOneBy(['type' => AbsenceType::PaidLeave]); if (null === $policy) { $policy = new AbsencePolicy(); $policy->setType(AbsenceType::PaidLeave); $policy->setCountWorkingDaysOnly(true); $policy->setActive(true); $this->em->persist($policy); } else { $policy->setActive(true); } $this->em->flush(); } private function securityFor(User $user): Security { $security = $this->createMock(Security::class); $security->method('isGranted')->willReturn(true); $security->method('getUser')->willReturn($user); return $security; } private function createTool(User $actor): CreateAbsenceRequestTool { $c = self::getContainer(); return new CreateAbsenceRequestTool( $c->get(EntityManagerInterface::class), $c->get(\App\Repository\UserRepository::class), $c->get(\App\Repository\AbsencePolicyRepository::class), $c->get(\App\Repository\AbsenceRequestRepository::class), $c->get(\App\Service\AbsenceDayCalculator::class), $c->get(\App\Service\AbsenceBalanceService::class), $this->securityFor($actor), ); } public function testCreateReservesPendingDays(): void { $tool = $this->createTool($this->admin); // Lundi 2026-06-01 → vendredi 2026-06-05 = 5 jours ouvrés. $json = ($tool)( $this->employee->getId(), 'cp', '2026-06-01', '2026-06-05', ); $data = json_decode($json, true); self::assertSame('pending', $data['status']); self::assertSame(5.0, $data['countedDays']); self::assertSame($this->employee->getId(), $data['user']['id']); $balance = self::getContainer()->get(AbsenceBalanceRepository::class) ->findOneForPeriod($this->employee, AbsenceType::PaidLeave, '2026-2027'); self::assertNotNull($balance); self::assertSame(5.0, $balance->getPending()); } } ``` > Période attendue : `referencePeriodStart = '06-01'`, date `2026-06-01` ≥ `06-01` ⇒ période `2026-2027` (cf. `AbsenceBalanceService::periodFor`). - [ ] **Step 2 : Lancer le test — il échoue (classe outil absente)** Run: `docker exec php-lesstime-fpm php bin/phpunit tests/Functional/Mcp/AbsenceRequestLifecycleTest.php` Expected: FAIL — `Class "App\Mcp\Tool\Absence\CreateAbsenceRequestTool" not found`. - [ ] **Step 3 : Créer `CreateAbsenceRequestTool`** ```php security->isGranted('ROLE_USER')) { throw new AccessDeniedException('Access denied: ROLE_USER required.'); } $user = $this->userRepository->find($userId) ?? throw new InvalidArgumentException(sprintf('User with ID %d not found.', $userId)); $typeEnum = AbsenceType::tryFrom($type) ?? throw new InvalidArgumentException(sprintf('Unknown absence type "%s".', $type)); $start = new DateTimeImmutable($startDate); $end = new DateTimeImmutable($endDate); if ($end < $start) { throw new InvalidArgumentException('End date must be on or after start date.'); } $startHd = null !== $startHalfDay ? (HalfDay::tryFrom($startHalfDay) ?? throw new InvalidArgumentException(sprintf('Unknown half day "%s".', $startHalfDay))) : null; $endHd = null !== $endHalfDay ? (HalfDay::tryFrom($endHalfDay) ?? throw new InvalidArgumentException(sprintf('Unknown half day "%s".', $endHalfDay))) : null; $policy = $this->policyRepository->findOneByType($typeEnum); if (null === $policy || !$policy->isActive()) { throw new InvalidArgumentException('This absence type is not available.'); } if ($this->requestRepository->hasOverlap($user, $start, $end)) { throw new InvalidArgumentException('This request overlaps an existing absence.'); } $countedDays = $this->calculator->countWorkingDays($start, $end, $startHd, $endHd, $policy->isCountWorkingDaysOnly()); if ($countedDays <= 0.0) { throw new InvalidArgumentException('The selected range contains no working day.'); } $request = new AbsenceRequest(); $request->setUser($user); $request->setType($typeEnum); $request->setStartDate($start); $request->setEndDate($end); $request->setStartHalfDay($startHd); $request->setEndHalfDay($endHd); $request->setReason($reason); $request->setCountedDays($countedDays); $request->setStatus(AbsenceStatus::Pending); $request->setRejectionReason(null); $request->setCreatedAt(new DateTimeImmutable()); $this->entityManager->persist($request); $this->balanceService->reservePending($request); $this->entityManager->flush(); return json_encode(Serializer::absenceRequest($request)); } } ``` - [ ] **Step 4 : Relancer le test — il passe** Run: `docker exec php-lesstime-fpm php bin/phpunit tests/Functional/Mcp/AbsenceRequestLifecycleTest.php` Expected: PASS (1 test). - [ ] **Step 5 : Commit** ```bash git add src/Mcp/Tool/Absence/CreateAbsenceRequestTool.php tests/Functional/Mcp/AbsenceRequestLifecycleTest.php git commit -m "feat(mcp) : outil create-absence-request + test cycle de vie" ``` --- ### Task 6 : `review-absence-request` (réplique `AbsenceReviewProcessor`) **Files:** - Create: `src/Mcp/Tool/Absence/ReviewAbsenceRequestTool.php` - Modify: `tests/Functional/Mcp/AbsenceRequestLifecycleTest.php` - [ ] **Step 1 : Ajouter le test d'approbation (échoue d'abord)** Ajouter dans `AbsenceRequestLifecycleTest` une fabrique d'outil review + un test : ```php private function reviewTool(User $actor): \App\Mcp\Tool\Absence\ReviewAbsenceRequestTool { $c = self::getContainer(); return new \App\Mcp\Tool\Absence\ReviewAbsenceRequestTool( $c->get(EntityManagerInterface::class), $c->get(\App\Repository\AbsenceRequestRepository::class), $c->get(\App\Service\AbsenceBalanceService::class), $this->securityFor($actor), ); } public function testApproveMovesPendingToTaken(): void { $created = json_decode( ($this->createTool($this->admin))($this->employee->getId(), 'cp', '2026-06-01', '2026-06-05'), true, ); $json = ($this->reviewTool($this->admin))($created['id'], 'approve'); $data = json_decode($json, true); self::assertSame('approved', $data['status']); self::assertSame($this->admin->getId(), $data['reviewedBy']['id']); $balance = self::getContainer()->get(AbsenceBalanceRepository::class) ->findOneForPeriod($this->employee, AbsenceType::PaidLeave, '2026-2027'); self::assertSame(0.0, $balance->getPending()); self::assertSame(5.0, $balance->getTaken()); } ``` - [ ] **Step 2 : Lancer — échoue (classe absente)** Run: `docker exec php-lesstime-fpm php bin/phpunit tests/Functional/Mcp/AbsenceRequestLifecycleTest.php --filter testApprove` Expected: FAIL — `ReviewAbsenceRequestTool` not found. - [ ] **Step 3 : Créer `ReviewAbsenceRequestTool`** ```php security->isGranted('ROLE_ADMIN')) { throw new AccessDeniedException('Access denied: ROLE_ADMIN required.'); } if (!in_array($decision, ['approve', 'reject'], true)) { throw new InvalidArgumentException('decision must be "approve" or "reject".'); } $request = $this->requestRepository->find($id); if (null === $request) { throw new InvalidArgumentException(sprintf('AbsenceRequest with ID %d not found.', $id)); } if (AbsenceStatus::Pending !== $request->getStatus()) { throw new InvalidArgumentException('Only a pending request can be reviewed.'); } $admin = $this->security->getUser(); \assert($admin instanceof User); if ('approve' === $decision) { $request->setStatus(AbsenceStatus::Approved); $request->setRejectionReason(null); $this->balanceService->applyApproval($request); } else { if (null === $rejectionReason || '' === trim($rejectionReason)) { throw new InvalidArgumentException('A reason is required when rejecting a request.'); } $request->setStatus(AbsenceStatus::Rejected); $request->setRejectionReason($rejectionReason); $this->balanceService->release($request, false); } $request->setReviewedAt(new DateTimeImmutable()); $request->setReviewedBy($admin); $this->entityManager->flush(); return json_encode(Serializer::absenceRequest($request)); } } ``` - [ ] **Step 4 : Relancer — passe** Run: `docker exec php-lesstime-fpm php bin/phpunit tests/Functional/Mcp/AbsenceRequestLifecycleTest.php` Expected: PASS (2 tests). - [ ] **Step 5 : Commit** ```bash git add src/Mcp/Tool/Absence/ReviewAbsenceRequestTool.php tests/Functional/Mcp/AbsenceRequestLifecycleTest.php git commit -m "feat(mcp) : outil review-absence-request (approve/reject)" ``` --- ### Task 7 : `cancel-absence-request` + `delete-absence-request` **Files:** - Create: `src/Mcp/Tool/Absence/CancelAbsenceRequestTool.php` - Create: `src/Mcp/Tool/Absence/DeleteAbsenceRequestTool.php` - Modify: `tests/Functional/Mcp/AbsenceRequestLifecycleTest.php` - [ ] **Step 1 : Ajouter le test d'annulation d'une demande approuvée (admin) — échoue d'abord** ```php private function cancelTool(User $actor): \App\Mcp\Tool\Absence\CancelAbsenceRequestTool { $c = self::getContainer(); return new \App\Mcp\Tool\Absence\CancelAbsenceRequestTool( $c->get(EntityManagerInterface::class), $c->get(\App\Repository\AbsenceRequestRepository::class), $c->get(\App\Service\AbsenceBalanceService::class), $this->securityFor($actor), ); } public function testAdminCancelApprovedReleasesTaken(): void { $created = json_decode( ($this->createTool($this->admin))($this->employee->getId(), 'cp', '2026-06-01', '2026-06-05'), true, ); ($this->reviewTool($this->admin))($created['id'], 'approve'); $data = json_decode(($this->cancelTool($this->admin))($created['id']), true); self::assertSame('cancelled', $data['status']); $balance = self::getContainer()->get(AbsenceBalanceRepository::class) ->findOneForPeriod($this->employee, AbsenceType::PaidLeave, '2026-2027'); self::assertSame(0.0, $balance->getTaken()); self::assertSame(0.0, $balance->getPending()); } ``` - [ ] **Step 2 : Lancer — échoue (classe absente)** Run: `docker exec php-lesstime-fpm php bin/phpunit tests/Functional/Mcp/AbsenceRequestLifecycleTest.php --filter testAdminCancel` Expected: FAIL — `CancelAbsenceRequestTool` not found. - [ ] **Step 3 : Créer `CancelAbsenceRequestTool`** ```php security->isGranted('ROLE_USER')) { throw new AccessDeniedException('Access denied: ROLE_USER required.'); } $request = $this->requestRepository->find($id); if (null === $request) { throw new InvalidArgumentException(sprintf('AbsenceRequest with ID %d not found.', $id)); } $isAdmin = $this->security->isGranted('ROLE_ADMIN'); $status = $request->getStatus(); if (AbsenceStatus::Pending === $status) { $this->balanceService->release($request, false); } elseif (AbsenceStatus::Approved === $status) { if (!$isAdmin) { throw new AccessDeniedException('Only an admin can cancel an approved request.'); } $this->balanceService->release($request, true); } else { throw new InvalidArgumentException('This request can no longer be cancelled.'); } $request->setStatus(AbsenceStatus::Cancelled); $this->entityManager->flush(); return json_encode(Serializer::absenceRequest($request)); } } ``` > Note : en MCP, l'acteur est le propriétaire du token (souvent admin), donc on ne réplique pas le contrôle « only your own request » du Processor (pas de notion d'employé courant ici). L'annulation d'une PENDING par token non-admin reste autorisée, ce qui est cohérent avec un usage d'administration. - [ ] **Step 4 : Créer `DeleteAbsenceRequestTool`** ```php security->isGranted('ROLE_ADMIN')) { throw new AccessDeniedException('Access denied: ROLE_ADMIN required.'); } $request = $this->requestRepository->find($id); if (null === $request) { throw new InvalidArgumentException(sprintf('AbsenceRequest with ID %d not found.', $id)); } $this->entityManager->remove($request); $this->entityManager->flush(); return json_encode(['success' => true, 'message' => sprintf('AbsenceRequest %d deleted.', $id)]); } } ``` - [ ] **Step 5 : Lancer toute la suite — passe** Run: `docker exec php-lesstime-fpm php bin/phpunit tests/Functional/Mcp/AbsenceRequestLifecycleTest.php` Expected: PASS (3 tests). - [ ] **Step 6 : Commit** ```bash git add src/Mcp/Tool/Absence/CancelAbsenceRequestTool.php src/Mcp/Tool/Absence/DeleteAbsenceRequestTool.php tests/Functional/Mcp/AbsenceRequestLifecycleTest.php git commit -m "feat(mcp) : outils cancel/delete absence-request" ``` --- ## JALON 2 — Trous CRUD : projets, groupes, métadonnées de tâches ### Task 8 : `delete-project` + `delete-group` **Files:** - Create: `src/Mcp/Tool/Project/DeleteProjectTool.php` - Create: `src/Mcp/Tool/TaskMeta/DeleteGroupTool.php` - [ ] **Step 1 : Créer `DeleteProjectTool`** ```php security->isGranted('ROLE_ADMIN')) { throw new AccessDeniedException('Access denied: ROLE_ADMIN required.'); } $project = $this->projectRepository->find($id); if (null === $project) { throw new InvalidArgumentException(sprintf('Project with ID %d not found.', $id)); } $name = $project->getName(); $this->entityManager->remove($project); $this->entityManager->flush(); return json_encode(['success' => true, 'message' => sprintf('Project "%s" deleted.', $name)]); } } ``` - [ ] **Step 2 : Créer `DeleteGroupTool`** ```php security->isGranted('ROLE_USER')) { throw new AccessDeniedException('Access denied: ROLE_USER required.'); } $group = $this->taskGroupRepository->find($id); if (null === $group) { throw new InvalidArgumentException(sprintf('TaskGroup with ID %d not found.', $id)); } $title = $group->getTitle(); $this->entityManager->remove($group); $this->entityManager->flush(); return json_encode(['success' => true, 'message' => sprintf('Group "%s" deleted.', $title)]); } } ``` > ⚠️ Vérifier le comportement de suppression de groupe : si la relation `Task.group` n'a pas `onDelete: SET NULL`, Doctrine lèvera une contrainte FK. Étape de vérification ci-dessous. - [ ] **Step 3 : Vérifier la contrainte FK sur Task.group** Run: `grep -nA3 "private.*TaskGroup .group\|class.*group" src/Entity/Task.php | grep -i "joincolumn\|ondelete"` Si aucune règle `onDelete: 'SET NULL'` n'apparaît et que la colonne est nullable, le `remove` peut échouer tant que des tâches référencent le groupe. Dans ce cas, dans `DeleteGroupTool::__invoke`, avant `remove`, dissocier les tâches : ```php foreach ($group->getTasks() as $task) { $task->setGroup(null); } ``` (N'ajouter ce bloc que si `Task` expose `getTasks()` côté groupe — sinon ignorer car la BDD gère déjà `SET NULL`.) - [ ] **Step 4 : Lint** Run: `docker exec php-lesstime-fpm php -l src/Mcp/Tool/Project/DeleteProjectTool.php && docker exec php-lesstime-fpm php -l src/Mcp/Tool/TaskMeta/DeleteGroupTool.php` Expected: `No syntax errors detected` ×2 - [ ] **Step 5 : Commit** ```bash git add src/Mcp/Tool/Project/DeleteProjectTool.php src/Mcp/Tool/TaskMeta/DeleteGroupTool.php git commit -m "feat(mcp) : outils delete-project et delete-group" ``` --- ### Task 9 : CRUD `tag` — `create-tag`, `update-tag`, `delete-tag` **Files:** - Create: `src/Mcp/Tool/TaskMeta/CreateTagTool.php` - Create: `src/Mcp/Tool/TaskMeta/UpdateTagTool.php` - Create: `src/Mcp/Tool/TaskMeta/DeleteTagTool.php` Helper de sortie : réutiliser `Serializer::tagsWithColor` n'accepte qu'une Collection ; pour un tag unique, renvoyer un tableau inline `['id' => ..., 'label' => ..., 'color' => ...]`. - [ ] **Step 1 : Créer `CreateTagTool`** ```php security->isGranted('ROLE_USER')) { throw new AccessDeniedException('Access denied: ROLE_USER required.'); } $tag = new TaskTag(); $tag->setLabel($label); if (null !== $color) { $tag->setColor($color); } $this->entityManager->persist($tag); $this->entityManager->flush(); return json_encode(['id' => $tag->getId(), 'label' => $tag->getLabel(), 'color' => $tag->getColor()]); } } ``` - [ ] **Step 2 : Créer `UpdateTagTool`** ```php security->isGranted('ROLE_USER')) { throw new AccessDeniedException('Access denied: ROLE_USER required.'); } $tag = $this->tagRepository->find($id); if (null === $tag) { throw new InvalidArgumentException(sprintf('TaskTag with ID %d not found.', $id)); } if (null !== $label) { $tag->setLabel($label); } if (null !== $color) { $tag->setColor($color); } $this->entityManager->flush(); return json_encode(['id' => $tag->getId(), 'label' => $tag->getLabel(), 'color' => $tag->getColor()]); } } ``` - [ ] **Step 3 : Créer `DeleteTagTool`** ```php security->isGranted('ROLE_USER')) { throw new AccessDeniedException('Access denied: ROLE_USER required.'); } $tag = $this->tagRepository->find($id); if (null === $tag) { throw new InvalidArgumentException(sprintf('TaskTag with ID %d not found.', $id)); } $label = $tag->getLabel(); $this->entityManager->remove($tag); $this->entityManager->flush(); return json_encode(['success' => true, 'message' => sprintf('Tag "%s" deleted.', $label)]); } } ``` - [ ] **Step 4 : Lint les 3 fichiers** Run: `for f in Create Update Delete; do docker exec php-lesstime-fpm php -l src/Mcp/Tool/TaskMeta/${f}TagTool.php; done` Expected: `No syntax errors detected` ×3 - [ ] **Step 5 : Commit** ```bash git add src/Mcp/Tool/TaskMeta/CreateTagTool.php src/Mcp/Tool/TaskMeta/UpdateTagTool.php src/Mcp/Tool/TaskMeta/DeleteTagTool.php git commit -m "feat(mcp) : CRUD tag" ``` --- ### Task 10 : CRUD `effort` — `create-effort`, `update-effort`, `delete-effort` `TaskEffort` n'a qu'un champ `label` (length 50), pas de couleur. **Files:** - Create: `src/Mcp/Tool/TaskMeta/CreateEffortTool.php` - Create: `src/Mcp/Tool/TaskMeta/UpdateEffortTool.php` - Create: `src/Mcp/Tool/TaskMeta/DeleteEffortTool.php` - [ ] **Step 1 : Créer `CreateEffortTool`** ```php security->isGranted('ROLE_USER')) { throw new AccessDeniedException('Access denied: ROLE_USER required.'); } $effort = new TaskEffort(); $effort->setLabel($label); $this->entityManager->persist($effort); $this->entityManager->flush(); return json_encode(['id' => $effort->getId(), 'label' => $effort->getLabel()]); } } ``` - [ ] **Step 2 : Créer `UpdateEffortTool`** ```php security->isGranted('ROLE_USER')) { throw new AccessDeniedException('Access denied: ROLE_USER required.'); } $effort = $this->effortRepository->find($id); if (null === $effort) { throw new InvalidArgumentException(sprintf('TaskEffort with ID %d not found.', $id)); } $effort->setLabel($label); $this->entityManager->flush(); return json_encode(['id' => $effort->getId(), 'label' => $effort->getLabel()]); } } ``` - [ ] **Step 3 : Créer `DeleteEffortTool`** ```php security->isGranted('ROLE_USER')) { throw new AccessDeniedException('Access denied: ROLE_USER required.'); } $effort = $this->effortRepository->find($id); if (null === $effort) { throw new InvalidArgumentException(sprintf('TaskEffort with ID %d not found.', $id)); } $label = $effort->getLabel(); $this->entityManager->remove($effort); $this->entityManager->flush(); return json_encode(['success' => true, 'message' => sprintf('Effort "%s" deleted.', $label)]); } } ``` - [ ] **Step 4 : Lint** Run: `for f in Create Update Delete; do docker exec php-lesstime-fpm php -l src/Mcp/Tool/TaskMeta/${f}EffortTool.php; done` Expected: `No syntax errors detected` ×3 - [ ] **Step 5 : Commit** ```bash git add src/Mcp/Tool/TaskMeta/CreateEffortTool.php src/Mcp/Tool/TaskMeta/UpdateEffortTool.php src/Mcp/Tool/TaskMeta/DeleteEffortTool.php git commit -m "feat(mcp) : CRUD effort" ``` --- ### Task 11 : CRUD `priority` — `create-priority`, `update-priority`, `delete-priority` `TaskPriority` a `label` + `color`. Mêmes formes que les tags. **Files:** - Create: `src/Mcp/Tool/TaskMeta/CreatePriorityTool.php` - Create: `src/Mcp/Tool/TaskMeta/UpdatePriorityTool.php` - Create: `src/Mcp/Tool/TaskMeta/DeletePriorityTool.php` - [ ] **Step 1 : Créer `CreatePriorityTool`** ```php security->isGranted('ROLE_USER')) { throw new AccessDeniedException('Access denied: ROLE_USER required.'); } $priority = new TaskPriority(); $priority->setLabel($label); if (null !== $color) { $priority->setColor($color); } $this->entityManager->persist($priority); $this->entityManager->flush(); return json_encode(['id' => $priority->getId(), 'label' => $priority->getLabel(), 'color' => $priority->getColor()]); } } ``` - [ ] **Step 2 : Créer `UpdatePriorityTool`** ```php security->isGranted('ROLE_USER')) { throw new AccessDeniedException('Access denied: ROLE_USER required.'); } $priority = $this->priorityRepository->find($id); if (null === $priority) { throw new InvalidArgumentException(sprintf('TaskPriority with ID %d not found.', $id)); } if (null !== $label) { $priority->setLabel($label); } if (null !== $color) { $priority->setColor($color); } $this->entityManager->flush(); return json_encode(['id' => $priority->getId(), 'label' => $priority->getLabel(), 'color' => $priority->getColor()]); } } ``` - [ ] **Step 3 : Créer `DeletePriorityTool`** ```php security->isGranted('ROLE_USER')) { throw new AccessDeniedException('Access denied: ROLE_USER required.'); } $priority = $this->priorityRepository->find($id); if (null === $priority) { throw new InvalidArgumentException(sprintf('TaskPriority with ID %d not found.', $id)); } $label = $priority->getLabel(); $this->entityManager->remove($priority); $this->entityManager->flush(); return json_encode(['success' => true, 'message' => sprintf('Priority "%s" deleted.', $label)]); } } ``` - [ ] **Step 4 : Lint** Run: `for f in Create Update Delete; do docker exec php-lesstime-fpm php -l src/Mcp/Tool/TaskMeta/${f}PriorityTool.php; done` Expected: `No syntax errors detected` ×3 - [ ] **Step 5 : Commit** ```bash git add src/Mcp/Tool/TaskMeta/CreatePriorityTool.php src/Mcp/Tool/TaskMeta/UpdatePriorityTool.php src/Mcp/Tool/TaskMeta/DeletePriorityTool.php git commit -m "feat(mcp) : CRUD priority" ``` --- ### Task 12 : CRUD `status` (couplé au workflow) `TaskStatus` appartient à un `Workflow` (`workflow` non nullable) et porte une `category` (`StatusCategory`: todo|in_progress|blocked|review|done), `label`, `color` (défaut `#222783`), `position`, `isFinal`. **Files:** - Create: `src/Mcp/Tool/TaskMeta/CreateStatusTool.php` - Create: `src/Mcp/Tool/TaskMeta/UpdateStatusTool.php` - Create: `src/Mcp/Tool/TaskMeta/DeleteStatusTool.php` - [ ] **Step 1 : Créer `CreateStatusTool`** ```php security->isGranted('ROLE_ADMIN')) { throw new AccessDeniedException('Access denied: ROLE_ADMIN required.'); } $workflow = $this->workflowRepository->find($workflowId); if (null === $workflow) { throw new InvalidArgumentException(sprintf('Workflow with ID %d not found.', $workflowId)); } $categoryEnum = StatusCategory::tryFrom($category) ?? throw new InvalidArgumentException(sprintf('Unknown status category "%s".', $category)); $status = new TaskStatus(); $status->setWorkflow($workflow); $status->setLabel($label); $status->setCategory($categoryEnum); if (null !== $color) { $status->setColor($color); } if (null !== $position) { $status->setPosition($position); } if (null !== $isFinal) { $status->setIsFinal($isFinal); } $this->entityManager->persist($status); $this->entityManager->flush(); return json_encode([ 'id' => $status->getId(), 'label' => $status->getLabel(), 'color' => $status->getColor(), 'position' => $status->getPosition(), 'isFinal' => $status->getIsFinal(), 'category' => $status->getCategory()->value, 'workflowId' => $workflow->getId(), ]); } } ``` > Vérifier les noms exacts des setters de `TaskStatus` (`setCategory`, `setIsFinal`, `setPosition`, `setColor`, `setWorkflow`) avec : `grep -nE "public function set" src/Entity/TaskStatus.php`. Ajuster si l'un diffère. - [ ] **Step 2 : Créer `UpdateStatusTool`** ```php security->isGranted('ROLE_ADMIN')) { throw new AccessDeniedException('Access denied: ROLE_ADMIN required.'); } $status = $this->statusRepository->find($id); if (null === $status) { throw new InvalidArgumentException(sprintf('TaskStatus with ID %d not found.', $id)); } if (null !== $label) { $status->setLabel($label); } if (null !== $category) { $status->setCategory( StatusCategory::tryFrom($category) ?? throw new InvalidArgumentException(sprintf('Unknown status category "%s".', $category)), ); } if (null !== $color) { $status->setColor($color); } if (null !== $position) { $status->setPosition($position); } if (null !== $isFinal) { $status->setIsFinal($isFinal); } $this->entityManager->flush(); return json_encode([ 'id' => $status->getId(), 'label' => $status->getLabel(), 'color' => $status->getColor(), 'position' => $status->getPosition(), 'isFinal' => $status->getIsFinal(), 'category' => $status->getCategory()->value, 'workflowId' => $status->getWorkflow()?->getId(), ]); } } ``` - [ ] **Step 3 : Créer `DeleteStatusTool`** ```php security->isGranted('ROLE_ADMIN')) { throw new AccessDeniedException('Access denied: ROLE_ADMIN required.'); } $status = $this->statusRepository->find($id); if (null === $status) { throw new InvalidArgumentException(sprintf('TaskStatus with ID %d not found.', $id)); } $label = $status->getLabel(); $this->entityManager->remove($status); $this->entityManager->flush(); return json_encode(['success' => true, 'message' => sprintf('Status "%s" deleted.', $label)]); } } ``` - [ ] **Step 4 : Lint** Run: `for f in Create Update Delete; do docker exec php-lesstime-fpm php -l src/Mcp/Tool/TaskMeta/${f}StatusTool.php; done` Expected: `No syntax errors detected` ×3 - [ ] **Step 5 : Commit** ```bash git add src/Mcp/Tool/TaskMeta/CreateStatusTool.php src/Mcp/Tool/TaskMeta/UpdateStatusTool.php src/Mcp/Tool/TaskMeta/DeleteStatusTool.php git commit -m "feat(mcp) : CRUD status (couplé workflow)" ``` --- ## JALON 3 — Clients, users, documentation ### Task 13 : CRUD `client` — `get/create/update/delete-client` **Files:** - Create: `src/Mcp/Tool/Reference/GetClientTool.php` - Create: `src/Mcp/Tool/Reference/CreateClientTool.php` - Create: `src/Mcp/Tool/Reference/UpdateClientTool.php` - Create: `src/Mcp/Tool/Reference/DeleteClientTool.php` - [ ] **Step 1 : Créer `GetClientTool`** ```php security->isGranted('ROLE_ADMIN')) { throw new AccessDeniedException('Access denied: ROLE_ADMIN required.'); } $client = $this->clientRepository->find($id); if (null === $client) { throw new InvalidArgumentException(sprintf('Client with ID %d not found.', $id)); } return json_encode(Serializer::client($client)); } } ``` - [ ] **Step 2 : Créer `CreateClientTool`** ```php security->isGranted('ROLE_ADMIN')) { throw new AccessDeniedException('Access denied: ROLE_ADMIN required.'); } $client = new Client(); $client->setName($name); $client->setEmail($email); $client->setPhone($phone); $client->setStreet($street); $client->setCity($city); $client->setPostalCode($postalCode); $this->entityManager->persist($client); $this->entityManager->flush(); return json_encode(Serializer::client($client)); } } ``` - [ ] **Step 3 : Créer `UpdateClientTool`** ```php security->isGranted('ROLE_ADMIN')) { throw new AccessDeniedException('Access denied: ROLE_ADMIN required.'); } $client = $this->clientRepository->find($id); if (null === $client) { throw new InvalidArgumentException(sprintf('Client with ID %d not found.', $id)); } if (null !== $name) { $client->setName($name); } if (null !== $email) { $client->setEmail($email); } if (null !== $phone) { $client->setPhone($phone); } if (null !== $street) { $client->setStreet($street); } if (null !== $city) { $client->setCity($city); } if (null !== $postalCode) { $client->setPostalCode($postalCode); } $this->entityManager->flush(); return json_encode(Serializer::client($client)); } } ``` - [ ] **Step 4 : Créer `DeleteClientTool`** ```php security->isGranted('ROLE_ADMIN')) { throw new AccessDeniedException('Access denied: ROLE_ADMIN required.'); } $client = $this->clientRepository->find($id); if (null === $client) { throw new InvalidArgumentException(sprintf('Client with ID %d not found.', $id)); } $name = $client->getName(); $this->entityManager->remove($client); $this->entityManager->flush(); return json_encode(['success' => true, 'message' => sprintf('Client "%s" deleted.', $name)]); } } ``` - [ ] **Step 5 : Lint** Run: `for f in Get Create Update Delete; do docker exec php-lesstime-fpm php -l src/Mcp/Tool/Reference/${f}ClientTool.php; done` Expected: `No syntax errors detected` ×4 - [ ] **Step 6 : Commit** ```bash git add src/Mcp/Tool/Reference/GetClientTool.php src/Mcp/Tool/Reference/CreateClientTool.php src/Mcp/Tool/Reference/UpdateClientTool.php src/Mcp/Tool/Reference/DeleteClientTool.php git commit -m "feat(mcp) : CRUD client" ``` --- ### Task 14 : `get-user` + `update-user` (champs RH/profil) Pas de création ni de gestion mot de passe/rôles (décision utilisateur). **Files:** - Create: `src/Mcp/Tool/Reference/GetUserTool.php` - Create: `src/Mcp/Tool/Reference/UpdateUserTool.php` - [ ] **Step 1 : Créer `GetUserTool`** ```php security->isGranted('ROLE_ADMIN')) { throw new AccessDeniedException('Access denied: ROLE_ADMIN required.'); } $user = $this->userRepository->find($id); if (null === $user) { throw new InvalidArgumentException(sprintf('User with ID %d not found.', $id)); } return json_encode(Serializer::userFull($user)); } } ``` - [ ] **Step 2 : Créer `UpdateUserTool`** ```php security->isGranted('ROLE_ADMIN')) { throw new AccessDeniedException('Access denied: ROLE_ADMIN required.'); } $user = $this->userRepository->find($id); if (null === $user) { throw new InvalidArgumentException(sprintf('User with ID %d not found.', $id)); } if (null !== $isEmployee) { $user->setIsEmployee($isEmployee); } if (null !== $hireDate) { $user->setHireDate(new DateTimeImmutable($hireDate)); } if (null !== $endDate) { $user->setEndDate(new DateTimeImmutable($endDate)); } if (null !== $contractType) { $user->setContractType( ContractType::tryFrom($contractType) ?? throw new InvalidArgumentException(sprintf('Unknown contract type "%s".', $contractType)), ); } if (null !== $workTimeRatio) { $user->setWorkTimeRatio($workTimeRatio); } if (null !== $annualLeaveDays) { $user->setAnnualLeaveDays($annualLeaveDays); } if (null !== $referencePeriodStart) { $user->setReferencePeriodStart($referencePeriodStart); } if (null !== $initialLeaveBalance) { $user->setInitialLeaveBalance($initialLeaveBalance); } if (null !== $familySituation) { $user->setFamilySituation( FamilySituation::tryFrom($familySituation) ?? throw new InvalidArgumentException(sprintf('Unknown family situation "%s".', $familySituation)), ); } if (null !== $nbChildren) { $user->setNbChildren($nbChildren); } $this->entityManager->flush(); return json_encode(Serializer::userFull($user)); } } ``` - [ ] **Step 3 : Lint** Run: `docker exec php-lesstime-fpm php -l src/Mcp/Tool/Reference/GetUserTool.php && docker exec php-lesstime-fpm php -l src/Mcp/Tool/Reference/UpdateUserTool.php` Expected: `No syntax errors detected` ×2 - [ ] **Step 4 : Commit** ```bash git add src/Mcp/Tool/Reference/GetUserTool.php src/Mcp/Tool/Reference/UpdateUserTool.php git commit -m "feat(mcp) : get-user et update-user (champs RH)" ``` --- ### Task 15 : Mettre à jour les instructions MCP **Files:** - Modify: `config/packages/mcp.yaml` - [ ] **Step 1 : Corriger/enrichir le bloc `instructions`** Remplacer la ligne fausse sur les statuts globaux et ajouter le domaine Absences. Dans `config/packages/mcp.yaml`, remplacer : ``` Statuses, priorities, efforts, and tags are GLOBAL (shared across all projects). Groups are PER-PROJECT (each group belongs to one project). ``` par : ``` Priorities, efforts, and tags are GLOBAL (shared across all projects). Statuses belong to a WORKFLOW (not global) — use list-workflows and list-statuses to see how they group; create-status requires a workflowId and a category. Groups are PER-PROJECT (each group belongs to one project). Absences: manage employee leave with absence-request tools (list/get/create/review/ cancel/delete), absence-policy tools (list/update) and absence-balance tools (list/update). create-absence-request takes an explicit userId to act on behalf of an employee; review/cancel keep the leave balances consistent (pending/taken). ``` - [ ] **Step 2 : Vérifier le YAML + cache** Run: `docker exec php-lesstime-fpm php bin/console lint:yaml config/packages/mcp.yaml && docker exec php-lesstime-fpm php bin/console cache:clear` Expected: `All 1 YAML files contain valid syntax.` puis cache vidé sans erreur. - [ ] **Step 3 : Commit** ```bash git add config/packages/mcp.yaml git commit -m "docs(mcp) : maj instructions (statuts par workflow + domaine absences)" ``` --- ## Vérification finale (après tous les jalons) - [ ] **Suite de tests complète** Run: `make test` Expected: tous les tests passent, dont `tests/Functional/Mcp/AbsenceRequestLifecycleTest.php` (3 tests). - [ ] **Code style PHP** Run: `make php-cs-fixer-allow-risky` Expected: fichiers reformatés conformes ; relancer `make test` si des fichiers changent. - [ ] **Découverte MCP en conditions réelles (STDIO)** Run: `docker exec -i php-lesstime-fpm php bin/console mcp:server <<< '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'` Expected : la réponse JSON liste les nouveaux outils (`create-absence-request`, `review-absence-request`, `delete-project`, `create-status`, `update-user`, etc.). > ⚠️ Rappel (mémoire projet) : le serveur MCP configuré côté client interroge la **PROD**. Ces outils ne piloteront le module Absences en prod qu'une fois la branche `feat/absence-management` déployée. En local, tester via la commande STDIO ci-dessus contre la base de dev. --- ## Couverture du spec (auto-revue) | Exigence du spec | Task | |---|---| | Serializer absences/client/user | 1 | | list/update absence-policy | 2 | | list/update absence-balance | 3 | | list/get absence-request | 4 | | create-absence-request (logique métier) | 5 | | review-absence-request | 6 | | cancel + delete absence-request | 7 | | delete-project, delete-group | 8 | | CRUD tag | 9 | | CRUD effort | 10 | | CRUD priority | 11 | | CRUD status (workflow + category) | 12 | | CRUD client | 13 | | get/update user (RH, sans password/roles) | 14 | | maj instructions mcp.yaml | 15 | Hors périmètre confirmé : create-user, password/roles, upload justificatif, mail/bookstack/gitea/zimbra/notifications.