Files
Lesstime/docs/superpowers/plans/2026-05-22-mcp-absence-crud-tools.md
Matthieu 2b148fa65a feat(absences) : outils MCP CRUD pour les absences
Expose le module Absences via le serveur MCP et comble les trous CRUD
existants (projets, groupes, métadonnées de tâches, clients, users RH).

Absences (réutilise AbsenceDayCalculator + AbsenceBalanceService pour ne
pas contourner la logique de soldes) :
- list/get/create/review/cancel/delete-absence-request
- list/update-absence-policy, list/update-absence-balance
- create-absence-request prend un userId explicite (agir au nom d'un employé) ;
  review/cancel maintiennent les soldes (pending/taken) cohérents
- AbsenceRequestRepository::findFiltered pour les filtres de liste

Trous CRUD comblés :
- delete-project, delete-group
- CRUD tag, effort, priority
- CRUD status (couplé au workflow, avec category)
- CRUD client, get/update-user (champs RH, sans password ni roles)

Sérialisation centralisée (Serializer::absenceRequest/Policy/Balance/client/userFull).
Instructions MCP (mcp.yaml) mises à jour : statuts par workflow + domaine absences.

Tests : tests/Functional/Mcp/AbsenceRequestLifecycleTest (création / approbation /
annulation admin) vérifient le cycle complet et la cohérence des soldes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:10:56 +02:00

2520 lines
82 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\<SousDossier>;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
// + repos/services/entities
#[McpTool(name: '<nom-kebab>', description: '<description>')]
class <Nom>Tool
{
public function __construct(/* deps injectées */) {}
public function __invoke(/* params */): string
{
if (!$this->security->isGranted('<ROLE>')) {
throw new AccessDeniedException('Access denied: <ROLE> 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<string, mixed>
*/
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<string, mixed>
*/
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<string, mixed>
*/
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<string, mixed>
*/
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<string, mixed>
*/
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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Absence;
use App\Mcp\Tool\Serializer;
use App\Repository\AbsencePolicyRepository;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(name: 'list-absence-policies', description: 'List all absence policies (one per absence type) with their rules: days/year, days/event, notice period, working-days mode, active flag.')]
class ListAbsencePoliciesTool
{
public function __construct(
private readonly AbsencePolicyRepository $policyRepository,
private readonly Security $security,
) {}
public function __invoke(): string
{
if (!$this->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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Absence;
use App\Mcp\Tool\Serializer;
use App\Repository\AbsencePolicyRepository;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'update-absence-policy', description: 'Update an absence policy (admin). Only provided fields change.')]
class UpdateAbsencePolicyTool
{
public function __construct(
private readonly AbsencePolicyRepository $policyRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(
int $id,
?float $daysPerYear = null,
?float $daysPerEvent = null,
?bool $justificationRequired = null,
?int $noticeDays = null,
?bool $countWorkingDaysOnly = null,
?bool $active = null,
): string {
if (!$this->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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Absence;
use App\Enum\AbsenceType;
use App\Mcp\Tool\Serializer;
use App\Repository\AbsenceBalanceRepository;
use App\Repository\UserRepository;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'list-absence-balances', description: 'List leave balances. Optional filters: userId, type (cp, mariage_pacs, conge_parental, deces, maladie), period (e.g. "2025-2026" or "2025").')]
class ListAbsenceBalancesTool
{
public function __construct(
private readonly AbsenceBalanceRepository $balanceRepository,
private readonly UserRepository $userRepository,
private readonly Security $security,
) {}
public function __invoke(?int $userId = null, ?string $type = null, ?string $period = null): string
{
if (!$this->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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Absence;
use App\Mcp\Tool\Serializer;
use App\Repository\AbsenceBalanceRepository;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'update-absence-balance', description: 'Manually adjust a leave balance (admin regularisation). Only provided buckets change: acquired (N-1), acquiring (N), taken.')]
class UpdateAbsenceBalanceTool
{
public function __construct(
private readonly AbsenceBalanceRepository $balanceRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(
int $id,
?float $acquired = null,
?float $acquiring = null,
?float $taken = null,
): string {
if (!$this->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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Absence;
use App\Enum\AbsenceStatus;
use App\Enum\AbsenceType;
use App\Mcp\Tool\Serializer;
use App\Repository\AbsenceRequestRepository;
use App\Repository\UserRepository;
use DateTimeImmutable;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'list-absence-requests', description: 'List absence requests. Optional filters: userId, status (pending, approved, rejected, cancelled), type (cp, mariage_pacs, conge_parental, deces, maladie), from/to (YYYY-MM-DD, overlap window). Without userId returns all employees.')]
class ListAbsenceRequestsTool
{
public function __construct(
private readonly AbsenceRequestRepository $requestRepository,
private readonly UserRepository $userRepository,
private readonly Security $security,
) {}
public function __invoke(
?int $userId = null,
?string $status = null,
?string $type = null,
?string $from = null,
?string $to = null,
): string {
if (!$this->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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Absence;
use App\Mcp\Tool\Serializer;
use App\Repository\AbsenceRequestRepository;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'get-absence-request', description: 'Get a single absence request by ID.')]
class GetAbsenceRequestTool
{
public function __construct(
private readonly AbsenceRequestRepository $requestRepository,
private readonly Security $security,
) {}
public function __invoke(int $id): string
{
if (!$this->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
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Mcp;
use App\Entity\AbsencePolicy;
use App\Entity\User;
use App\Enum\AbsenceType;
use App\Mcp\Tool\Absence\CreateAbsenceRequestTool;
use App\Repository\AbsenceBalanceRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Bundle\SecurityBundle\Security;
/**
* @internal
*/
class AbsenceRequestLifecycleTest extends KernelTestCase
{
private EntityManagerInterface $em;
private User $employee;
private User $admin;
protected function setUp(): void
{
self::bootKernel();
$this->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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Absence;
use App\Entity\AbsenceRequest;
use App\Enum\AbsenceStatus;
use App\Enum\AbsenceType;
use App\Enum\HalfDay;
use App\Mcp\Tool\Serializer;
use App\Repository\AbsencePolicyRepository;
use App\Repository\AbsenceRequestRepository;
use App\Repository\UserRepository;
use App\Service\AbsenceBalanceService;
use App\Service\AbsenceDayCalculator;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'create-absence-request', description: 'Create an absence request on behalf of an employee (userId). Validates active policy + no overlap, computes deducted working days, sets status=pending and reserves the days in the pending balance. type: cp|mariage_pacs|conge_parental|deces|maladie. Dates YYYY-MM-DD. halfDay (matin|apres_midi) on a boundary subtracts 0.5.')]
class CreateAbsenceRequestTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly UserRepository $userRepository,
private readonly AbsencePolicyRepository $policyRepository,
private readonly AbsenceRequestRepository $requestRepository,
private readonly AbsenceDayCalculator $calculator,
private readonly AbsenceBalanceService $balanceService,
private readonly Security $security,
) {}
public function __invoke(
int $userId,
string $type,
string $startDate,
string $endDate,
?string $startHalfDay = null,
?string $endHalfDay = null,
?string $reason = null,
): string {
if (!$this->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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Absence;
use App\Entity\User;
use App\Enum\AbsenceStatus;
use App\Mcp\Tool\Serializer;
use App\Repository\AbsenceRequestRepository;
use App\Service\AbsenceBalanceService;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'review-absence-request', description: 'Approve or reject a PENDING absence request (admin). decision = "approve" or "reject"; rejectionReason is required when rejecting. Approving moves the days from pending to taken; rejecting releases the reserved days.')]
class ReviewAbsenceRequestTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly AbsenceRequestRepository $requestRepository,
private readonly AbsenceBalanceService $balanceService,
private readonly Security $security,
) {}
public function __invoke(int $id, string $decision, ?string $rejectionReason = null): string
{
if (!$this->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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Absence;
use App\Enum\AbsenceStatus;
use App\Mcp\Tool\Serializer;
use App\Repository\AbsenceRequestRepository;
use App\Service\AbsenceBalanceService;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'cancel-absence-request', description: 'Cancel an absence request. A PENDING request releases its reserved days; an APPROVED request can only be cancelled by an admin and credits the taken days back. Already cancelled/rejected requests cannot be cancelled.')]
class CancelAbsenceRequestTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly AbsenceRequestRepository $requestRepository,
private readonly AbsenceBalanceService $balanceService,
private readonly Security $security,
) {}
public function __invoke(int $id): string
{
if (!$this->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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Absence;
use App\Repository\AbsenceRequestRepository;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'delete-absence-request', description: 'Permanently delete an absence request (admin). Does not adjust balances — use cancel-absence-request first if the days must be credited back.')]
class DeleteAbsenceRequestTool
{
public function __construct(
private readonly AbsenceRequestRepository $requestRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(int $id): string
{
if (!$this->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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Project;
use App\Repository\ProjectRepository;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'delete-project', description: 'Permanently delete a project and all its tasks (admin). Irreversible.')]
class DeleteProjectTool
{
public function __construct(
private readonly ProjectRepository $projectRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(int $id): string
{
if (!$this->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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Repository\TaskGroupRepository;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'delete-group', description: 'Delete a task group. Tasks in the group are not deleted; they become ungrouped.')]
class DeleteGroupTool
{
public function __construct(
private readonly TaskGroupRepository $taskGroupRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(int $id): string
{
if (!$this->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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Entity\TaskTag;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(name: 'create-tag', description: 'Create a global task tag. Tags are shared across all projects.')]
class CreateTagTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(string $label, ?string $color = null): string
{
if (!$this->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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Repository\TaskTagRepository;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'update-tag', description: 'Update a task tag. Only provided fields change.')]
class UpdateTagTool
{
public function __construct(
private readonly TaskTagRepository $tagRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(int $id, ?string $label = null, ?string $color = null): string
{
if (!$this->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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Repository\TaskTagRepository;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'delete-tag', description: 'Delete a global task tag. It is removed from all tasks that use it.')]
class DeleteTagTool
{
public function __construct(
private readonly TaskTagRepository $tagRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(int $id): string
{
if (!$this->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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Entity\TaskEffort;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(name: 'create-effort', description: 'Create a global task effort level (label only).')]
class CreateEffortTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(string $label): string
{
if (!$this->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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Repository\TaskEffortRepository;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'update-effort', description: 'Rename a task effort level.')]
class UpdateEffortTool
{
public function __construct(
private readonly TaskEffortRepository $effortRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(int $id, string $label): string
{
if (!$this->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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Repository\TaskEffortRepository;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'delete-effort', description: 'Delete a task effort level.')]
class DeleteEffortTool
{
public function __construct(
private readonly TaskEffortRepository $effortRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(int $id): string
{
if (!$this->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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Entity\TaskPriority;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(name: 'create-priority', description: 'Create a global task priority (label + color).')]
class CreatePriorityTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(string $label, ?string $color = null): string
{
if (!$this->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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Repository\TaskPriorityRepository;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'update-priority', description: 'Update a task priority. Only provided fields change.')]
class UpdatePriorityTool
{
public function __construct(
private readonly TaskPriorityRepository $priorityRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(int $id, ?string $label = null, ?string $color = null): string
{
if (!$this->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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Repository\TaskPriorityRepository;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'delete-priority', description: 'Delete a task priority.')]
class DeletePriorityTool
{
public function __construct(
private readonly TaskPriorityRepository $priorityRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(int $id): string
{
if (!$this->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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Entity\TaskStatus;
use App\Enum\StatusCategory;
use App\Repository\WorkflowRepository;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'create-status', description: 'Create a task status inside a workflow (admin). Statuses are NOT global: each belongs to a workflow (use list-workflows for IDs). category = todo|in_progress|blocked|review|done.')]
class CreateStatusTool
{
public function __construct(
private readonly WorkflowRepository $workflowRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(
int $workflowId,
string $label,
string $category,
?string $color = null,
?int $position = null,
?bool $isFinal = null,
): string {
if (!$this->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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Enum\StatusCategory;
use App\Repository\TaskStatusRepository;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'update-status', description: 'Update a task status (admin). Only provided fields change. category = todo|in_progress|blocked|review|done.')]
class UpdateStatusTool
{
public function __construct(
private readonly TaskStatusRepository $statusRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(
int $id,
?string $label = null,
?string $category = null,
?string $color = null,
?int $position = null,
?bool $isFinal = null,
): string {
if (!$this->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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Repository\TaskStatusRepository;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'delete-status', description: 'Delete a task status from its workflow (admin). Fails at the database level if tasks still use it.')]
class DeleteStatusTool
{
public function __construct(
private readonly TaskStatusRepository $statusRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(int $id): string
{
if (!$this->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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Reference;
use App\Mcp\Tool\Serializer;
use App\Repository\ClientRepository;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'get-client', description: 'Get a client by ID with full contact details.')]
class GetClientTool
{
public function __construct(
private readonly ClientRepository $clientRepository,
private readonly Security $security,
) {}
public function __invoke(int $id): string
{
if (!$this->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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Reference;
use App\Entity\Client;
use App\Mcp\Tool\Serializer;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(name: 'create-client', description: 'Create a client (admin). Only name is required.')]
class CreateClientTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(
string $name,
?string $email = null,
?string $phone = null,
?string $street = null,
?string $city = null,
?string $postalCode = null,
): string {
if (!$this->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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Reference;
use App\Mcp\Tool\Serializer;
use App\Repository\ClientRepository;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'update-client', description: 'Update a client (admin). Only provided fields change.')]
class UpdateClientTool
{
public function __construct(
private readonly ClientRepository $clientRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(
int $id,
?string $name = null,
?string $email = null,
?string $phone = null,
?string $street = null,
?string $city = null,
?string $postalCode = null,
): string {
if (!$this->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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Reference;
use App\Repository\ClientRepository;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'delete-client', description: 'Delete a client (admin). Fails if the client still has projects attached.')]
class DeleteClientTool
{
public function __construct(
private readonly ClientRepository $clientRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(int $id): string
{
if (!$this->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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Reference;
use App\Mcp\Tool\Serializer;
use App\Repository\UserRepository;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'get-user', description: 'Get a user by ID with full HR profile (employee flag, hire/end date, contract, work-time ratio, leave entitlement, reference period, family situation).')]
class GetUserTool
{
public function __construct(
private readonly UserRepository $userRepository,
private readonly Security $security,
) {}
public function __invoke(int $id): string
{
if (!$this->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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Reference;
use App\Enum\ContractType;
use App\Enum\FamilySituation;
use App\Mcp\Tool\Serializer;
use App\Repository\UserRepository;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'update-user', description: 'Update a user HR/profile fields (admin). Does NOT change password or roles. contractType = CDI|CDD|STAGE|ALTERNANCE|AUTRE. familySituation = CELIBATAIRE|MARIE|PACSE|DIVORCE|VEUF. hireDate/endDate as YYYY-MM-DD. referencePeriodStart as MM-DD (e.g. 06-01).')]
class UpdateUserTool
{
public function __construct(
private readonly UserRepository $userRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(
int $id,
?bool $isEmployee = null,
?string $hireDate = null,
?string $endDate = null,
?string $contractType = null,
?float $workTimeRatio = null,
?float $annualLeaveDays = null,
?string $referencePeriodStart = null,
?float $initialLeaveBalance = null,
?string $familySituation = null,
?int $nbChildren = null,
): string {
if (!$this->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.