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>
This commit is contained in:
Matthieu
2026-05-22 14:10:56 +02:00
parent 2a0b202d32
commit 2b148fa65a
36 changed files with 4536 additions and 1 deletions
@@ -0,0 +1,59 @@
<?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));
}
}
@@ -0,0 +1,104 @@
<?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));
}
}
@@ -0,0 +1,41 @@
<?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)]);
}
}
@@ -0,0 +1,37 @@
<?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));
}
}
@@ -0,0 +1,53 @@
<?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));
}
}
@@ -0,0 +1,31 @@
<?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));
}
}
@@ -0,0 +1,68 @@
<?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));
}
}
@@ -0,0 +1,74 @@
<?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 assert;
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));
}
}
@@ -0,0 +1,55 @@
<?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));
}
}
@@ -0,0 +1,67 @@
<?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));
}
}
@@ -0,0 +1,42 @@
<?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)]);
}
}
@@ -0,0 +1,47 @@
<?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));
}
}
@@ -0,0 +1,42 @@
<?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)]);
}
}
+37
View File
@@ -0,0 +1,37 @@
<?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));
}
}
+37
View File
@@ -0,0 +1,37 @@
<?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));
}
}
@@ -0,0 +1,67 @@
<?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));
}
}
+92
View File
@@ -0,0 +1,92 @@
<?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));
}
}
+106
View File
@@ -4,6 +4,10 @@ declare(strict_types=1);
namespace App\Mcp\Tool;
use App\Entity\AbsenceBalance;
use App\Entity\AbsencePolicy;
use App\Entity\AbsenceRequest;
use App\Entity\Client;
use App\Entity\Project;
use App\Entity\Task;
use App\Entity\TaskDocument;
@@ -287,4 +291,106 @@ final class Serializer
'uploadedBy' => self::user($doc->getUploadedBy()),
])->toArray();
}
/**
* @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->getIsEmployee(),
'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(),
];
}
}
@@ -0,0 +1,34 @@
<?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()]);
}
}
@@ -0,0 +1,37 @@
<?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()]);
}
}
@@ -0,0 +1,74 @@
<?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(),
]);
}
}
+38
View File
@@ -0,0 +1,38 @@
<?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()]);
}
}
@@ -0,0 +1,42 @@
<?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)]);
}
}
+42
View File
@@ -0,0 +1,42 @@
<?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)]);
}
}
@@ -0,0 +1,42 @@
<?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)]);
}
}
@@ -0,0 +1,42 @@
<?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)]);
}
}
+42
View File
@@ -0,0 +1,42 @@
<?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)]);
}
}
@@ -0,0 +1,41 @@
<?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()]);
}
}
@@ -0,0 +1,46 @@
<?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()]);
}
}
@@ -0,0 +1,74 @@
<?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(),
]);
}
}
+47
View File
@@ -0,0 +1,47 @@
<?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()]);
}
}
@@ -7,6 +7,7 @@ namespace App\Repository;
use App\Entity\AbsenceRequest;
use App\Entity\User;
use App\Enum\AbsenceStatus;
use App\Enum\AbsenceType;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
@@ -71,4 +72,35 @@ class AbsenceRequestRepository extends ServiceEntityRepository
->getResult()
;
}
/**
* @return AbsenceRequest[]
*/
public function findFiltered(
?User $user = null,
?AbsenceStatus $status = null,
?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();
}
}