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

82 KiB
Raw Blame History

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

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 :

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) :

    /**
     * @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
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

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

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

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

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
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 :

    /**
     * @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

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

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

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-0106-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

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
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 :

    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

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

    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

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

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

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

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 :

        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
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 tagcreate-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

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

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

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
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 effortcreate-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

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

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

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
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 prioritycreate-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

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

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

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

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

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

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
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 clientget/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

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

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

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

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

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

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