# 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.
