feat(mcp) : outils MCP Directory pour prestataires, contacts, adresses et rapports
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 41s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 59s

Ajoute 20 nouveaux outils MCP pour permettre à Claude (ou tout client MCP) de
remplir un dossier client / prospect / prestataire complet — onglets
Information, Contact, Adresse et Rapport — sans passer par l'UI.

Entités couvertes (CRUD complet, 5 outils chacune) :
- Prestataire : create / update / get / list / delete
- Contact : create / update / get / list / delete
- Address : create / update / get / list / delete
- CommercialReport : create / update / get / list / delete

Détails :
- Contact / Address / CommercialReport doivent être rattachés à exactement
  un parent parmi clientId, prospectId, prestataireId (validation côté tool).
- get-client, get-prospect et get-prestataire renvoient désormais un payload
  enrichi avec la liste de leurs contacts, adresses et rapports liés : un
  seul appel pour reconstruire l'onglet entier.
- Pour CommercialReport, le type (note / call / meeting / email) et la date
  occurredAt sont validés ; l'auteur est rempli automatiquement par le
  listener existant.
- Sécurité : ROLE_ADMIN aligné sur les autres outils MCP de Directory (pas
  de migration vers les permissions RBAC fines pour rester cohérent).

Plumbing :
- Repositories Contact / Address / CommercialReport : ajout de findBy() sur
  les interfaces (l'implémentation Doctrine l'a déjà via ServiceEntityRepository).
- Bindings interface -> implémentation Doctrine ajoutés dans services.yaml
  pour Prestataire / Contact / Address / CommercialReport.
- Sérialiseur partagé étendu : prestataire / contact / address /
  commercialReport / reportDocument.

Vérification : 86 outils MCP exposés au total (66 avant + 20 ajoutés), test
end-to-end via le transport HTTP (create-prestataire + create-contact +
create-address + create-commercial-report + get-prestataire renvoyant le
dossier complet). Suite PHPUnit verte.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-24 20:36:46 +02:00
parent 94e6abcbaa
commit 99626b89da
23 changed files with 1276 additions and 4 deletions
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
use App\Module\Directory\Domain\Entity\Address;
use App\Module\Directory\Domain\Repository\ClientRepositoryInterface;
use App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface;
use App\Module\Directory\Domain\Repository\ProspectRepositoryInterface;
use App\Shared\Infrastructure\Mcp\Serializer;
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-address',
description: 'Create an address (admin) attached to exactly one of clientId / prospectId / prestataireId. Country defaults to FR (ISO 3166 alpha-2).'
)]
class CreateAddressTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly ClientRepositoryInterface $clientRepository,
private readonly ProspectRepositoryInterface $prospectRepository,
private readonly PrestataireRepositoryInterface $prestataireRepository,
private readonly Security $security,
) {}
public function __invoke(
?int $clientId = null,
?int $prospectId = null,
?int $prestataireId = null,
?string $label = null,
?string $street = null,
?string $streetComplement = null,
?string $postalCode = null,
?string $city = null,
?string $country = null,
): string {
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$parents = array_filter([$clientId, $prospectId, $prestataireId], static fn ($v) => null !== $v);
if (1 !== count($parents)) {
throw new InvalidArgumentException('Exactly one of clientId, prospectId or prestataireId must be provided.');
}
$address = new Address();
if (null !== $clientId) {
$client = $this->clientRepository->findById($clientId);
if (null === $client) {
throw new InvalidArgumentException(sprintf('Client with ID %d not found.', $clientId));
}
$address->setClient($client);
}
if (null !== $prospectId) {
$prospect = $this->prospectRepository->findById($prospectId);
if (null === $prospect) {
throw new InvalidArgumentException(sprintf('Prospect with ID %d not found.', $prospectId));
}
$address->setProspect($prospect);
}
if (null !== $prestataireId) {
$prestataire = $this->prestataireRepository->findById($prestataireId);
if (null === $prestataire) {
throw new InvalidArgumentException(sprintf('Prestataire with ID %d not found.', $prestataireId));
}
$address->setPrestataire($prestataire);
}
$address->setLabel($label);
$address->setStreet($street);
$address->setStreetComplement($streetComplement);
$address->setPostalCode($postalCode);
$address->setCity($city);
if (null !== $country) {
if (2 !== strlen($country)) {
throw new InvalidArgumentException('country must be a 2-letter ISO 3166 alpha-2 code (e.g., FR, BE).');
}
$address->setCountry(strtoupper($country));
}
$this->entityManager->persist($address);
$this->entityManager->flush();
return json_encode(Serializer::address($address));
}
}
@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
use App\Module\Directory\Domain\Entity\CommercialReport;
use App\Module\Directory\Domain\Enum\ReportType;
use App\Module\Directory\Domain\Repository\ClientRepositoryInterface;
use App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface;
use App\Module\Directory\Domain\Repository\ProspectRepositoryInterface;
use App\Shared\Infrastructure\Mcp\Serializer;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
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-commercial-report',
description: 'Create a commercial report (admin) attached to exactly one of clientId / prospectId / prestataireId. Type defaults to "note". Allowed types: note, call, meeting, email. Date defaults to today if omitted.'
)]
class CreateCommercialReportTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly ClientRepositoryInterface $clientRepository,
private readonly ProspectRepositoryInterface $prospectRepository,
private readonly PrestataireRepositoryInterface $prestataireRepository,
private readonly Security $security,
) {}
public function __invoke(
string $subject,
?int $clientId = null,
?int $prospectId = null,
?int $prestataireId = null,
?string $body = null,
?string $occurredAt = null,
?string $type = null,
): string {
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$parents = array_filter([$clientId, $prospectId, $prestataireId], static fn ($v) => null !== $v);
if (1 !== count($parents)) {
throw new InvalidArgumentException('Exactly one of clientId, prospectId or prestataireId must be provided.');
}
$report = new CommercialReport();
$report->setSubject($subject);
$report->setBody($body);
if (null !== $clientId) {
$client = $this->clientRepository->findById($clientId);
if (null === $client) {
throw new InvalidArgumentException(sprintf('Client with ID %d not found.', $clientId));
}
$report->setClient($client);
}
if (null !== $prospectId) {
$prospect = $this->prospectRepository->findById($prospectId);
if (null === $prospect) {
throw new InvalidArgumentException(sprintf('Prospect with ID %d not found.', $prospectId));
}
$report->setProspect($prospect);
}
if (null !== $prestataireId) {
$prestataire = $this->prestataireRepository->findById($prestataireId);
if (null === $prestataire) {
throw new InvalidArgumentException(sprintf('Prestataire with ID %d not found.', $prestataireId));
}
$report->setPrestataire($prestataire);
}
try {
$date = null === $occurredAt
? new DateTimeImmutable('today')
: new DateTimeImmutable($occurredAt);
} catch (Exception $e) {
throw new InvalidArgumentException(sprintf('Invalid occurredAt "%s": %s', $occurredAt, $e->getMessage()));
}
$report->setOccurredAt($date);
if (null !== $type) {
$typeEnum = ReportType::tryFrom($type);
if (null === $typeEnum) {
throw new InvalidArgumentException(sprintf('Invalid type "%s". Allowed: note, call, meeting, email.', $type));
}
$report->setType($typeEnum);
}
$this->entityManager->persist($report);
$this->entityManager->flush();
return json_encode(Serializer::commercialReport($report));
}
}
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
use App\Module\Directory\Domain\Entity\Contact;
use App\Module\Directory\Domain\Repository\ClientRepositoryInterface;
use App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface;
use App\Module\Directory\Domain\Repository\ProspectRepositoryInterface;
use App\Shared\Infrastructure\Mcp\Serializer;
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-contact',
description: 'Create a contact (admin) attached to exactly one of clientId / prospectId / prestataireId. All fields except the parent are optional.'
)]
class CreateContactTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly ClientRepositoryInterface $clientRepository,
private readonly ProspectRepositoryInterface $prospectRepository,
private readonly PrestataireRepositoryInterface $prestataireRepository,
private readonly Security $security,
) {}
public function __invoke(
?int $clientId = null,
?int $prospectId = null,
?int $prestataireId = null,
?string $firstName = null,
?string $lastName = null,
?string $jobTitle = null,
?string $email = null,
?string $phonePrimary = null,
?string $phoneSecondary = null,
): string {
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$parents = array_filter([$clientId, $prospectId, $prestataireId], static fn ($v) => null !== $v);
if (1 !== count($parents)) {
throw new InvalidArgumentException('Exactly one of clientId, prospectId or prestataireId must be provided.');
}
$contact = new Contact();
if (null !== $clientId) {
$client = $this->clientRepository->findById($clientId);
if (null === $client) {
throw new InvalidArgumentException(sprintf('Client with ID %d not found.', $clientId));
}
$contact->setClient($client);
}
if (null !== $prospectId) {
$prospect = $this->prospectRepository->findById($prospectId);
if (null === $prospect) {
throw new InvalidArgumentException(sprintf('Prospect with ID %d not found.', $prospectId));
}
$contact->setProspect($prospect);
}
if (null !== $prestataireId) {
$prestataire = $this->prestataireRepository->findById($prestataireId);
if (null === $prestataire) {
throw new InvalidArgumentException(sprintf('Prestataire with ID %d not found.', $prestataireId));
}
$contact->setPrestataire($prestataire);
}
$contact->setFirstName($firstName);
$contact->setLastName($lastName);
$contact->setJobTitle($jobTitle);
$contact->setEmail($email);
$contact->setPhonePrimary($phonePrimary);
$contact->setPhoneSecondary($phoneSecondary);
$this->entityManager->persist($contact);
$this->entityManager->flush();
return json_encode(Serializer::contact($contact));
}
}
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
use App\Module\Directory\Domain\Entity\Prestataire;
use App\Shared\Infrastructure\Mcp\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-prestataire', description: 'Create a prestataire / service provider (admin). Only name is required.')]
class CreatePrestataireTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(
string $name,
?string $email = null,
?string $phone = null,
?string $website = null,
): string {
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$prestataire = new Prestataire();
$prestataire->setName($name);
$prestataire->setEmail($email);
$prestataire->setPhone($phone);
$prestataire->setWebsite($website);
$this->entityManager->persist($prestataire);
$this->entityManager->flush();
return json_encode(Serializer::prestataire($prestataire));
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
use App\Module\Directory\Domain\Repository\AddressRepositoryInterface;
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-address', description: 'Delete an address (admin).')]
class DeleteAddressTool
{
public function __construct(
private readonly AddressRepositoryInterface $addressRepository,
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.');
}
$address = $this->addressRepository->findById($id);
if (null === $address) {
throw new InvalidArgumentException(sprintf('Address with ID %d not found.', $id));
}
$this->entityManager->remove($address);
$this->entityManager->flush();
return json_encode(['success' => true, 'message' => sprintf('Address #%d deleted.', $id)]);
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
use App\Module\Directory\Domain\Repository\CommercialReportRepositoryInterface;
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-commercial-report', description: 'Delete a commercial report (admin). Cascade removes its attached documents.')]
class DeleteCommercialReportTool
{
public function __construct(
private readonly CommercialReportRepositoryInterface $reportRepository,
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.');
}
$report = $this->reportRepository->findById($id);
if (null === $report) {
throw new InvalidArgumentException(sprintf('CommercialReport with ID %d not found.', $id));
}
$this->entityManager->remove($report);
$this->entityManager->flush();
return json_encode(['success' => true, 'message' => sprintf('CommercialReport #%d deleted.', $id)]);
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
use App\Module\Directory\Domain\Repository\ContactRepositoryInterface;
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-contact', description: 'Delete a contact (admin).')]
class DeleteContactTool
{
public function __construct(
private readonly ContactRepositoryInterface $contactRepository,
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.');
}
$contact = $this->contactRepository->findById($id);
if (null === $contact) {
throw new InvalidArgumentException(sprintf('Contact with ID %d not found.', $id));
}
$this->entityManager->remove($contact);
$this->entityManager->flush();
return json_encode(['success' => true, 'message' => sprintf('Contact #%d deleted.', $id)]);
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
use App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface;
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-prestataire', description: 'Delete a prestataire (admin). Cascade removes its contacts, addresses and reports.')]
class DeletePrestataireTool
{
public function __construct(
private readonly PrestataireRepositoryInterface $prestataireRepository,
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.');
}
$prestataire = $this->prestataireRepository->findById($id);
if (null === $prestataire) {
throw new InvalidArgumentException(sprintf('Prestataire with ID %d not found.', $id));
}
$name = $prestataire->getName();
$this->entityManager->remove($prestataire);
$this->entityManager->flush();
return json_encode(['success' => true, 'message' => sprintf('Prestataire "%s" deleted.', $name)]);
}
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
use App\Module\Directory\Domain\Repository\AddressRepositoryInterface;
use App\Shared\Infrastructure\Mcp\Serializer;
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-address', description: 'Get an address by ID.')]
class GetAddressTool
{
public function __construct(
private readonly AddressRepositoryInterface $addressRepository,
private readonly Security $security,
) {}
public function __invoke(int $id): string
{
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$address = $this->addressRepository->findById($id);
if (null === $address) {
throw new InvalidArgumentException(sprintf('Address with ID %d not found.', $id));
}
return json_encode(Serializer::address($address));
}
}
@@ -4,7 +4,10 @@ declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
use App\Module\Directory\Domain\Repository\AddressRepositoryInterface;
use App\Module\Directory\Domain\Repository\ClientRepositoryInterface;
use App\Module\Directory\Domain\Repository\CommercialReportRepositoryInterface;
use App\Module\Directory\Domain\Repository\ContactRepositoryInterface;
use App\Shared\Infrastructure\Mcp\Serializer;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
@@ -13,11 +16,14 @@ use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'get-client', description: 'Get a client by ID with full contact details.')]
#[McpTool(name: 'get-client', description: 'Get a client by ID, with its linked contacts, addresses, and commercial reports.')]
class GetClientTool
{
public function __construct(
private readonly ClientRepositoryInterface $clientRepository,
private readonly ContactRepositoryInterface $contactRepository,
private readonly AddressRepositoryInterface $addressRepository,
private readonly CommercialReportRepositoryInterface $reportRepository,
private readonly Security $security,
) {}
@@ -32,6 +38,20 @@ class GetClientTool
throw new InvalidArgumentException(sprintf('Client with ID %d not found.', $id));
}
return json_encode(Serializer::client($client));
$payload = Serializer::client($client);
$payload['contacts'] = array_map(
fn ($c) => Serializer::contact($c),
$this->contactRepository->findBy(['client' => $client], ['lastName' => 'ASC'])
);
$payload['addresses'] = array_map(
fn ($a) => Serializer::address($a),
$this->addressRepository->findBy(['client' => $client], ['id' => 'ASC'])
);
$payload['reports'] = array_map(
fn ($r) => Serializer::commercialReport($r),
$this->reportRepository->findBy(['client' => $client], ['occurredAt' => 'DESC'])
);
return json_encode($payload);
}
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
use App\Module\Directory\Domain\Repository\CommercialReportRepositoryInterface;
use App\Shared\Infrastructure\Mcp\Serializer;
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-commercial-report', description: 'Get a commercial report by ID, including its attached documents.')]
class GetCommercialReportTool
{
public function __construct(
private readonly CommercialReportRepositoryInterface $reportRepository,
private readonly Security $security,
) {}
public function __invoke(int $id): string
{
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$report = $this->reportRepository->findById($id);
if (null === $report) {
throw new InvalidArgumentException(sprintf('CommercialReport with ID %d not found.', $id));
}
return json_encode(Serializer::commercialReport($report));
}
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
use App\Module\Directory\Domain\Repository\ContactRepositoryInterface;
use App\Shared\Infrastructure\Mcp\Serializer;
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-contact', description: 'Get a contact by ID.')]
class GetContactTool
{
public function __construct(
private readonly ContactRepositoryInterface $contactRepository,
private readonly Security $security,
) {}
public function __invoke(int $id): string
{
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$contact = $this->contactRepository->findById($id);
if (null === $contact) {
throw new InvalidArgumentException(sprintf('Contact with ID %d not found.', $id));
}
return json_encode(Serializer::contact($contact));
}
}
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
use App\Module\Directory\Domain\Repository\AddressRepositoryInterface;
use App\Module\Directory\Domain\Repository\CommercialReportRepositoryInterface;
use App\Module\Directory\Domain\Repository\ContactRepositoryInterface;
use App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface;
use App\Shared\Infrastructure\Mcp\Serializer;
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-prestataire', description: 'Get a prestataire by ID, with its linked contacts, addresses, and commercial reports.')]
class GetPrestataireTool
{
public function __construct(
private readonly PrestataireRepositoryInterface $prestataireRepository,
private readonly ContactRepositoryInterface $contactRepository,
private readonly AddressRepositoryInterface $addressRepository,
private readonly CommercialReportRepositoryInterface $reportRepository,
private readonly Security $security,
) {}
public function __invoke(int $id): string
{
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$prestataire = $this->prestataireRepository->findById($id);
if (null === $prestataire) {
throw new InvalidArgumentException(sprintf('Prestataire with ID %d not found.', $id));
}
$payload = Serializer::prestataire($prestataire);
$payload['contacts'] = array_map(
fn ($c) => Serializer::contact($c),
$this->contactRepository->findBy(['prestataire' => $prestataire], ['lastName' => 'ASC'])
);
$payload['addresses'] = array_map(
fn ($a) => Serializer::address($a),
$this->addressRepository->findBy(['prestataire' => $prestataire], ['id' => 'ASC'])
);
$payload['reports'] = array_map(
fn ($r) => Serializer::commercialReport($r),
$this->reportRepository->findBy(['prestataire' => $prestataire], ['occurredAt' => 'DESC'])
);
return json_encode($payload);
}
}
@@ -4,6 +4,9 @@ declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
use App\Module\Directory\Domain\Repository\AddressRepositoryInterface;
use App\Module\Directory\Domain\Repository\CommercialReportRepositoryInterface;
use App\Module\Directory\Domain\Repository\ContactRepositoryInterface;
use App\Module\Directory\Domain\Repository\ProspectRepositoryInterface;
use App\Shared\Infrastructure\Mcp\Serializer;
use InvalidArgumentException;
@@ -13,11 +16,14 @@ use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'get-prospect', description: 'Get a prospect by ID with full details.')]
#[McpTool(name: 'get-prospect', description: 'Get a prospect by ID, with its linked contacts, addresses, and commercial reports.')]
class GetProspectTool
{
public function __construct(
private readonly ProspectRepositoryInterface $prospectRepository,
private readonly ContactRepositoryInterface $contactRepository,
private readonly AddressRepositoryInterface $addressRepository,
private readonly CommercialReportRepositoryInterface $reportRepository,
private readonly Security $security,
) {}
@@ -32,6 +38,20 @@ class GetProspectTool
throw new InvalidArgumentException(sprintf('Prospect with ID %d not found.', $id));
}
return json_encode(Serializer::prospect($prospect));
$payload = Serializer::prospect($prospect);
$payload['contacts'] = array_map(
fn ($c) => Serializer::contact($c),
$this->contactRepository->findBy(['prospect' => $prospect], ['lastName' => 'ASC'])
);
$payload['addresses'] = array_map(
fn ($a) => Serializer::address($a),
$this->addressRepository->findBy(['prospect' => $prospect], ['id' => 'ASC'])
);
$payload['reports'] = array_map(
fn ($r) => Serializer::commercialReport($r),
$this->reportRepository->findBy(['prospect' => $prospect], ['occurredAt' => 'DESC'])
);
return json_encode($payload);
}
}
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
use App\Module\Directory\Domain\Repository\AddressRepositoryInterface;
use App\Shared\Infrastructure\Mcp\Serializer;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(
name: 'list-addresses',
description: 'List addresses, optionally filtered by parent (at most one of clientId / prospectId / prestataireId).'
)]
class ListAddressesTool
{
public function __construct(
private readonly AddressRepositoryInterface $addressRepository,
private readonly Security $security,
) {}
public function __invoke(
?int $clientId = null,
?int $prospectId = null,
?int $prestataireId = null,
): string {
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$filters = array_filter([$clientId, $prospectId, $prestataireId], static fn ($v) => null !== $v);
if (count($filters) > 1) {
throw new InvalidArgumentException('At most one of clientId, prospectId or prestataireId can be provided.');
}
$criteria = [];
if (null !== $clientId) {
$criteria['client'] = $clientId;
}
if (null !== $prospectId) {
$criteria['prospect'] = $prospectId;
}
if (null !== $prestataireId) {
$criteria['prestataire'] = $prestataireId;
}
$addresses = $this->addressRepository->findBy($criteria, ['id' => 'ASC']);
return json_encode(array_map(fn ($a) => Serializer::address($a), $addresses));
}
}
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
use App\Module\Directory\Domain\Repository\CommercialReportRepositoryInterface;
use App\Shared\Infrastructure\Mcp\Serializer;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(
name: 'list-commercial-reports',
description: 'List commercial reports, optionally filtered by parent (at most one of clientId / prospectId / prestataireId). Returns reports ordered by occurredAt DESC.'
)]
class ListCommercialReportsTool
{
public function __construct(
private readonly CommercialReportRepositoryInterface $reportRepository,
private readonly Security $security,
) {}
public function __invoke(
?int $clientId = null,
?int $prospectId = null,
?int $prestataireId = null,
): string {
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$filters = array_filter([$clientId, $prospectId, $prestataireId], static fn ($v) => null !== $v);
if (count($filters) > 1) {
throw new InvalidArgumentException('At most one of clientId, prospectId or prestataireId can be provided.');
}
$criteria = [];
if (null !== $clientId) {
$criteria['client'] = $clientId;
}
if (null !== $prospectId) {
$criteria['prospect'] = $prospectId;
}
if (null !== $prestataireId) {
$criteria['prestataire'] = $prestataireId;
}
$reports = $this->reportRepository->findBy($criteria, ['occurredAt' => 'DESC']);
return json_encode(array_map(fn ($r) => Serializer::commercialReport($r), $reports));
}
}
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
use App\Module\Directory\Domain\Repository\ContactRepositoryInterface;
use App\Shared\Infrastructure\Mcp\Serializer;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(
name: 'list-contacts',
description: 'List contacts, optionally filtered by parent (at most one of clientId / prospectId / prestataireId).'
)]
class ListContactsTool
{
public function __construct(
private readonly ContactRepositoryInterface $contactRepository,
private readonly Security $security,
) {}
public function __invoke(
?int $clientId = null,
?int $prospectId = null,
?int $prestataireId = null,
): string {
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$filters = array_filter([$clientId, $prospectId, $prestataireId], static fn ($v) => null !== $v);
if (count($filters) > 1) {
throw new InvalidArgumentException('At most one of clientId, prospectId or prestataireId can be provided.');
}
$criteria = [];
if (null !== $clientId) {
$criteria['client'] = $clientId;
}
if (null !== $prospectId) {
$criteria['prospect'] = $prospectId;
}
if (null !== $prestataireId) {
$criteria['prestataire'] = $prestataireId;
}
$contacts = $this->contactRepository->findBy($criteria, ['lastName' => 'ASC']);
return json_encode(array_map(fn ($c) => Serializer::contact($c), $contacts));
}
}
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
use App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(name: 'list-prestataires', description: 'List all prestataires with their IDs, names, and emails. Use this to discover valid prestataire IDs.')]
class ListPrestatairesTool
{
public function __construct(
private readonly PrestataireRepositoryInterface $prestataireRepository,
private readonly Security $security,
) {}
public function __invoke(): string
{
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$prestataires = $this->prestataireRepository->findBy([], ['name' => 'ASC']);
return json_encode(array_map(fn ($p) => [
'id' => $p->getId(),
'name' => $p->getName(),
'email' => $p->getEmail(),
], $prestataires));
}
}
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
use App\Module\Directory\Domain\Repository\AddressRepositoryInterface;
use App\Shared\Infrastructure\Mcp\Serializer;
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-address',
description: 'Update an address (admin). Only provided fields change. Parent (client/prospect/prestataire) is immutable.'
)]
class UpdateAddressTool
{
public function __construct(
private readonly AddressRepositoryInterface $addressRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(
int $id,
?string $label = null,
?string $street = null,
?string $streetComplement = null,
?string $postalCode = null,
?string $city = null,
?string $country = null,
): string {
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$address = $this->addressRepository->findById($id);
if (null === $address) {
throw new InvalidArgumentException(sprintf('Address with ID %d not found.', $id));
}
if (null !== $label) {
$address->setLabel($label);
}
if (null !== $street) {
$address->setStreet($street);
}
if (null !== $streetComplement) {
$address->setStreetComplement($streetComplement);
}
if (null !== $postalCode) {
$address->setPostalCode($postalCode);
}
if (null !== $city) {
$address->setCity($city);
}
if (null !== $country) {
if (2 !== strlen($country)) {
throw new InvalidArgumentException('country must be a 2-letter ISO 3166 alpha-2 code (e.g., FR, BE).');
}
$address->setCountry(strtoupper($country));
}
$this->entityManager->flush();
return json_encode(Serializer::address($address));
}
}
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
use App\Module\Directory\Domain\Enum\ReportType;
use App\Module\Directory\Domain\Repository\CommercialReportRepositoryInterface;
use App\Shared\Infrastructure\Mcp\Serializer;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
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-commercial-report',
description: 'Update a commercial report (admin). Only provided fields change. Parent (client/prospect/prestataire) is immutable.'
)]
class UpdateCommercialReportTool
{
public function __construct(
private readonly CommercialReportRepositoryInterface $reportRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(
int $id,
?string $subject = null,
?string $body = null,
?string $occurredAt = null,
?string $type = null,
): string {
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$report = $this->reportRepository->findById($id);
if (null === $report) {
throw new InvalidArgumentException(sprintf('CommercialReport with ID %d not found.', $id));
}
if (null !== $subject) {
$report->setSubject($subject);
}
if (null !== $body) {
$report->setBody($body);
}
if (null !== $occurredAt) {
try {
$report->setOccurredAt(new DateTimeImmutable($occurredAt));
} catch (Exception $e) {
throw new InvalidArgumentException(sprintf('Invalid occurredAt "%s": %s', $occurredAt, $e->getMessage()));
}
}
if (null !== $type) {
$typeEnum = ReportType::tryFrom($type);
if (null === $typeEnum) {
throw new InvalidArgumentException(sprintf('Invalid type "%s". Allowed: note, call, meeting, email.', $type));
}
$report->setType($typeEnum);
}
$this->entityManager->flush();
return json_encode(Serializer::commercialReport($report));
}
}
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
use App\Module\Directory\Domain\Repository\ContactRepositoryInterface;
use App\Shared\Infrastructure\Mcp\Serializer;
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-contact',
description: 'Update a contact (admin). Only provided fields change. The parent (client/prospect/prestataire) is immutable — delete then recreate to re-attach.'
)]
class UpdateContactTool
{
public function __construct(
private readonly ContactRepositoryInterface $contactRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(
int $id,
?string $firstName = null,
?string $lastName = null,
?string $jobTitle = null,
?string $email = null,
?string $phonePrimary = null,
?string $phoneSecondary = null,
): string {
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$contact = $this->contactRepository->findById($id);
if (null === $contact) {
throw new InvalidArgumentException(sprintf('Contact with ID %d not found.', $id));
}
if (null !== $firstName) {
$contact->setFirstName($firstName);
}
if (null !== $lastName) {
$contact->setLastName($lastName);
}
if (null !== $jobTitle) {
$contact->setJobTitle($jobTitle);
}
if (null !== $email) {
$contact->setEmail($email);
}
if (null !== $phonePrimary) {
$contact->setPhonePrimary($phonePrimary);
}
if (null !== $phoneSecondary) {
$contact->setPhoneSecondary($phoneSecondary);
}
$this->entityManager->flush();
return json_encode(Serializer::contact($contact));
}
}
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
use App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface;
use App\Shared\Infrastructure\Mcp\Serializer;
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-prestataire', description: 'Update a prestataire (admin). Only provided fields change.')]
class UpdatePrestataireTool
{
public function __construct(
private readonly PrestataireRepositoryInterface $prestataireRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(
int $id,
?string $name = null,
?string $email = null,
?string $phone = null,
?string $website = null,
): string {
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$prestataire = $this->prestataireRepository->findById($id);
if (null === $prestataire) {
throw new InvalidArgumentException(sprintf('Prestataire with ID %d not found.', $id));
}
if (null !== $name) {
$prestataire->setName($name);
}
if (null !== $email) {
$prestataire->setEmail($email);
}
if (null !== $phone) {
$prestataire->setPhone($phone);
}
if (null !== $website) {
$prestataire->setWebsite($website);
}
$this->entityManager->flush();
return json_encode(Serializer::prestataire($prestataire));
}
}
@@ -8,8 +8,13 @@ use App\Module\Absence\Domain\Entity\AbsenceBalance;
use App\Module\Absence\Domain\Entity\AbsencePolicy;
use App\Module\Absence\Domain\Entity\AbsenceRequest;
use App\Module\Core\Domain\Entity\User;
use App\Module\Directory\Domain\Entity\Address;
use App\Module\Directory\Domain\Entity\Client;
use App\Module\Directory\Domain\Entity\CommercialReport;
use App\Module\Directory\Domain\Entity\Contact;
use App\Module\Directory\Domain\Entity\Prestataire;
use App\Module\Directory\Domain\Entity\Prospect;
use App\Module\Directory\Domain\Entity\ReportDocument;
use App\Module\ProjectManagement\Domain\Entity\Project;
use App\Module\ProjectManagement\Domain\Entity\TaskDocument;
use App\Module\ProjectManagement\Domain\Entity\TaskEffort;
@@ -374,6 +379,98 @@ final class Serializer
];
}
/**
* @return array<string, mixed>
*/
public static function prestataire(Prestataire $p): array
{
return [
'id' => $p->getId(),
'name' => $p->getName(),
'email' => $p->getEmail(),
'phone' => $p->getPhone(),
'website' => $p->getWebsite(),
];
}
/**
* @return array<string, mixed>
*/
public static function contact(Contact $c): array
{
return [
'id' => $c->getId(),
'firstName' => $c->getFirstName(),
'lastName' => $c->getLastName(),
'jobTitle' => $c->getJobTitle(),
'email' => $c->getEmail(),
'phonePrimary' => $c->getPhonePrimary(),
'phoneSecondary' => $c->getPhoneSecondary(),
'clientId' => $c->getClient()?->getId(),
'prospectId' => $c->getProspect()?->getId(),
'prestataireId' => $c->getPrestataire()?->getId(),
];
}
/**
* @return array<string, mixed>
*/
public static function address(Address $a): array
{
return [
'id' => $a->getId(),
'label' => $a->getLabel(),
'street' => $a->getStreet(),
'streetComplement' => $a->getStreetComplement(),
'postalCode' => $a->getPostalCode(),
'city' => $a->getCity(),
'country' => $a->getCountry(),
'clientId' => $a->getClient()?->getId(),
'prospectId' => $a->getProspect()?->getId(),
'prestataireId' => $a->getPrestataire()?->getId(),
];
}
/**
* @return array<string, mixed>
*/
public static function reportDocument(ReportDocument $d): array
{
return [
'id' => $d->getId(),
'originalName' => $d->getOriginalName(),
'mimeType' => $d->getMimeType(),
'size' => $d->getSize(),
'createdAt' => $d->getCreatedAt()?->format('c'),
'uploadedBy' => self::user($d->getUploadedBy()),
];
}
/**
* @return array<string, mixed>
*/
public static function commercialReport(CommercialReport $r): array
{
return [
'id' => $r->getId(),
'subject' => $r->getSubject(),
'body' => $r->getBody(),
'occurredAt' => $r->getOccurredAt()?->format('Y-m-d'),
'type' => $r->getType()->value,
'typeLabel' => $r->getType()->label(),
'author' => self::user($r->getAuthor()),
'clientId' => $r->getClient()?->getId(),
'prospectId' => $r->getProspect()?->getId(),
'prestataireId' => $r->getPrestataire()?->getId(),
'documents' => array_map(
fn (ReportDocument $d) => self::reportDocument($d),
$r->getDocuments()->toArray()
),
'createdAt' => $r->getCreatedAt()?->format('c'),
'updatedAt' => $r->getUpdatedAt()?->format('c'),
];
}
/**
* @return array<string, mixed>
*/