Compare commits

...

7 Commits

Author SHA1 Message Date
Matthieu
f965affc94 feat(mcp) : add MCP resources, documentation, and .mcp.json config
- 3 MCP resources: schema, roles, stats
- docs/mcp/README.md with full user guide (config, tools catalogue, workflows)
- .mcp.json for Claude Code stdio transport
- Design spec and implementation plan

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 15:49:00 +01:00
Matthieu
4340a0e13e feat(mcp) : add business tools — search, history, comments, custom fields, documents, model types
- search_inventory: global search across all 6 entity types
- get_entity_history + get_activity_log: audit trail access
- 4 comment tools: list, create, resolve, unresolved count
- 3 custom field tools: list values, upsert, delete
- 2 document tools: list, delete (upload via REST only)
- 6 model type tools: list, get, create, update, delete, sync
- 69 MCP tests pass total

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 15:00:37 +01:00
Matthieu
bd7259ed05 feat(mcp) : add Slots, Machine Links, Structure, and Clone tools
- list_slots + update_slots for composant/piece slots
- list/add/update/remove machine links (component, piece, product)
- get_machine_structure with full hierarchy
- clone_machine with all links and custom fields
- 52 MCP tests pass total

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:49:55 +01:00
Matthieu
2f173e766d feat(mcp) : add CRUD tools for Pieces, Composants, Machines
- 5 tools each: list, get, create, update, delete
- Piece: includes typePiece, constructeurs, prix (string)
- Composant: includes typeComposant, constructeurs, prix (string)
- Machine: includes site (required), constructeurs
- 40 MCP tests pass total

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:38:55 +01:00
Matthieu
4f1e136dc5 feat(mcp) : add CRUD tools for Sites, Constructeurs, Products
- 5 tools each: list, get, create, update, delete
- McpToolHelper extracted to AbstractApiTestCase for reuse
- DashboardStatsToolTest simplified to use base helpers
- 22 MCP tests pass

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:31:15 +01:00
Matthieu
e335f4c24c feat(mcp) : add stdio auth, dashboard stats PoC tool, and helper trait
- McpStdioAuthSubscriber for console transport auth via env vars
- DashboardStatsTool as PoC (validates MCP protocol flow)
- McpToolHelper trait for shared pagination/error utilities
- Key learning: #[McpTool] must be on CLASS, not method for __invoke

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:18:09 +01:00
Matthieu
46ea3ca8ad feat(mcp) : re-enable MCP bundle config after package install
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:09:29 +01:00
90 changed files with 8500 additions and 14 deletions

14
.mcp.json Normal file
View File

@@ -0,0 +1,14 @@
{
"mcpServers": {
"inventory": {
"command": "docker",
"args": [
"exec", "-i",
"-e", "MCP_PROFILE_ID=REPLACE_WITH_YOUR_PROFILE_ID",
"-e", "MCP_PROFILE_PASSWORD=REPLACE_WITH_YOUR_PASSWORD",
"php-inventory-apache",
"php", "bin/console", "mcp:server"
]
}
}
}

View File

@@ -8,6 +8,7 @@ use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
use Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle;
use Nelmio\CorsBundle\NelmioCorsBundle;
use Symfony\AI\McpBundle\McpBundle;
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
use Symfony\Bundle\SecurityBundle\SecurityBundle;
use Symfony\Bundle\TwigBundle\TwigBundle;
@@ -22,4 +23,5 @@ return [
ApiPlatformBundle::class => ['all' => true],
LexikJWTAuthenticationBundle::class => ['all' => true],
DAMADoctrineTestBundle::class => ['test' => true],
McpBundle::class => ['all' => true],
];

20
config/packages/mcp.yaml Normal file
View File

@@ -0,0 +1,20 @@
mcp:
app: 'inventory'
version: '1.0.0'
description: 'Inventory MCP Server - Gestion inventaire industriel (machines, pièces, composants, produits)'
instructions: |
Serveur MCP pour gérer un inventaire industriel.
Entités principales : Machine, Composant, Pièce, Produit, Site, Constructeur.
Utilisez search_inventory pour chercher dans toutes les entités.
Utilisez get_model_type pour comprendre la structure attendue avant de créer un composant ou une pièce.
Consultez la resource inventory://schema/entities pour voir le schéma complet.
Authentification requise : envoyez X-Profile-Id et X-Profile-Password dans les headers HTTP.
client_transports:
stdio: true
http: true
http:
path: /_mcp
session:
store: file
directory: '%kernel.cache_dir%/mcp-sessions'
ttl: 3600

View File

@@ -0,0 +1,6 @@
framework:
rate_limiter:
mcp_auth:
policy: sliding_window
limit: 5
interval: '1 minute'

View File

@@ -27,12 +27,11 @@ security:
pattern: ^/api/session/profiles?$
security: false
# TODO: re-enable when symfony/ai-mcp-bundle is installed
# mcp:
# pattern: ^/_mcp
# stateless: true
# custom_authenticators:
# - App\Mcp\Security\McpHeaderAuthenticator
mcp:
pattern: ^/_mcp
stateless: true
custom_authenticators:
- App\Mcp\Security\McpHeaderAuthenticator
api:
pattern: ^/api
@@ -56,7 +55,7 @@ security:
- { path: ^/api/admin, roles: ROLE_ADMIN }
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
- { path: ^/api/health$, roles: PUBLIC_ACCESS }
# - { path: ^/_mcp, roles: ROLE_USER } # TODO: re-enable with MCP
- { path: ^/_mcp, roles: ROLE_USER }
- { path: ^/docs, roles: PUBLIC_ACCESS }
- { path: ^/contexts, roles: PUBLIC_ACCESS }
- { path: ^/\.well-known, roles: PUBLIC_ACCESS }

View File

@@ -12,3 +12,7 @@ api_login_check:
controllers:
resource: routing.controllers
mcp:
resource: .
type: mcp

View File

@@ -18,8 +18,6 @@ services:
# this creates a service per class whose id is the fully-qualified class name
App\:
resource: '../src/'
exclude:
- '../src/Mcp/'
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones
@@ -36,10 +34,9 @@ services:
tags:
- { name: doctrine.event_subscriber }
# TODO: re-enable when symfony/ai-mcp-bundle is installed
# App\Mcp\Security\McpHeaderAuthenticator:
# arguments:
# $mcpAuthLimiter: '@limiter.mcp_auth'
App\Mcp\Security\McpHeaderAuthenticator:
arguments:
$mcpAuthLimiter: '@limiter.mcp_auth'
App\OpenApi\OpenApiDecorator:
decorates: 'api_platform.openapi.factory'

185
docs/mcp/README.md Normal file
View File

@@ -0,0 +1,185 @@
# MCP Server — Inventory
Serveur MCP (Model Context Protocol) pour l'application Inventory. Permet aux assistants IA (Claude, ChatGPT, Codex) de consulter et gérer l'inventaire industriel.
## Prérequis
- Un profil actif avec rôle suffisant (ROLE_VIEWER pour lecture, ROLE_GESTIONNAIRE pour écriture)
- Accès au tunnel pour les clients distants (Claude Desktop, ChatGPT Desktop)
- Docker Compose démarré (`make start`)
## Configuration par client
### Claude Code (local, stdio)
Le fichier `.mcp.json` à la racine du projet est déjà configuré. Remplacez les placeholders :
```json
{
"mcpServers": {
"inventory": {
"command": "docker",
"args": [
"exec", "-i",
"-e", "MCP_PROFILE_ID=VOTRE_PROFILE_ID",
"-e", "MCP_PROFILE_PASSWORD=VOTRE_PASSWORD",
"php-inventory-apache",
"php", "bin/console", "mcp:server"
]
}
}
}
```
### Claude Desktop (HTTP via tunnel)
Dans `claude_desktop_config.json` :
```json
{
"mcpServers": {
"inventory": {
"url": "https://inventory.company-tunnel.com/_mcp",
"headers": {
"X-Profile-Id": "VOTRE_PROFILE_ID",
"X-Profile-Password": "VOTRE_PASSWORD"
}
}
}
}
```
### ChatGPT Desktop / Codex
Meme principe HTTP avec l'URL du tunnel + headers d'auth.
## Catalogue des Tools
### Tools de haut niveau
| Tool | Description | Role |
|------|-------------|------|
| `search_inventory` | Recherche globale (machines, pieces, composants, produits, sites, constructeurs) | VIEWER |
| `get_machine_structure` | Hierarchie complete d'une machine | VIEWER |
| `clone_machine` | Clone une machine avec toute sa structure | GESTIONNAIRE |
| `get_dashboard_stats` | Statistiques globales | VIEWER |
| `get_entity_history` | Historique d'audit d'une entite | VIEWER |
| `get_activity_log` | Journal d'activite global | VIEWER |
### CRUD par entite
Pour chaque entite (Machine, Composant, Piece, Produit, Site, Constructeur) :
| Pattern | Exemple | Role |
|---------|---------|------|
| `list_{entite}s` | `list_machines` | VIEWER |
| `get_{entite}` | `get_machine` | VIEWER |
| `create_{entite}` | `create_machine` | GESTIONNAIRE |
| `update_{entite}` | `update_machine` | GESTIONNAIRE |
| `delete_{entite}` | `delete_machine` | GESTIONNAIRE |
### Slots
| Tool | Description | Role |
|------|-------------|------|
| `list_slots` | Lister les slots d'un composant ou piece | VIEWER |
| `update_slots` | Remplir/vider les slots | GESTIONNAIRE |
### Machine Links
| Tool | Description | Role |
|------|-------------|------|
| `list_machine_links` | Liens composant/piece/produit d'une machine | VIEWER |
| `add_machine_links` | Ajouter des liens | GESTIONNAIRE |
| `update_machine_link` | Modifier un lien | GESTIONNAIRE |
| `remove_machine_link` | Supprimer un lien | GESTIONNAIRE |
### Commentaires
| Tool | Description | Role |
|------|-------------|------|
| `list_comments` | Lister les commentaires d'une entite | VIEWER |
| `create_comment` | Creer un commentaire | VIEWER |
| `resolve_comment` | Resoudre un commentaire | GESTIONNAIRE |
| `get_unresolved_comments_count` | Nombre de commentaires non resolus | VIEWER |
### Custom Fields
| Tool | Description | Role |
|------|-------------|------|
| `list_custom_field_values` | Valeurs de champs perso d'une entite | VIEWER |
| `upsert_custom_field_values` | Creer/mettre a jour des valeurs | GESTIONNAIRE |
| `delete_custom_field_value` | Supprimer une valeur | GESTIONNAIRE |
### Documents
| Tool | Description | Role |
|------|-------------|------|
| `list_documents` | Lister les documents d'une entite | VIEWER |
| `delete_document` | Supprimer un document | GESTIONNAIRE |
> **Limitation :** L'upload de documents n'est pas supporte via MCP (protocole JSON uniquement). Utilisez l'API REST `/api/documents` (POST multipart).
### ModelTypes
| Tool | Description | Role |
|------|-------------|------|
| `list_model_types` | Lister par categorie | VIEWER |
| `get_model_type` | Detail avec skeleton requirements | VIEWER |
| `create_model_type` | Creer | GESTIONNAIRE |
| `update_model_type` | Modifier | GESTIONNAIRE |
| `delete_model_type` | Supprimer | GESTIONNAIRE |
| `sync_model_type` | Preview/sync skeleton | GESTIONNAIRE |
## Workflows guides
### Creer un composant complet
```
1. list_model_types(category: "composant") -> choisir le type
2. get_model_type(modelTypeId: "...") -> voir le skeleton
3. create_composant(name, reference, modelTypeId) -> cree + slots auto
4. search_inventory(query: "Roulement", types: "piece") -> trouver pieces
5. update_slots(slots: [{slotId, selectedPieceId}]) -> remplir
6. upsert_custom_field_values(entityType: "composant", entityId, fields: [...])
```
### Creer une machine complete (bottom-up)
```
1. Creer les produits necessaires
2. Creer les pieces (avec produits dans les slots)
3. Creer les composants (avec pieces dans les slots)
4. list_sites -> choisir le site
5. create_machine(name, siteId)
6. add_machine_links(machineId, links: [{type: "composant", entityId, quantity}])
7. upsert_custom_field_values(entityType: "machine", machineId, fields: [...])
```
## Resources MCP
| URI | Description |
|-----|-------------|
| `inventory://schema/entities` | Schema de toutes les entites |
| `inventory://roles` | Hierarchie des roles et permissions |
| `inventory://stats` | Statistiques globales |
## Roles & Permissions
```
ROLE_ADMIN > ROLE_GESTIONNAIRE > ROLE_VIEWER > ROLE_USER
```
- **VIEWER** : lecture, recherche, commentaires
- **GESTIONNAIRE** : ecriture (CRUD, slots, links, clone)
- **ADMIN** : gestion profils (via API REST uniquement)
## Troubleshooting
| Erreur | Cause | Solution |
|--------|-------|----------|
| `401 Unauthorized` | Credentials invalides | Verifier X-Profile-Id et X-Profile-Password |
| `Permission denied: ROLE_GESTIONNAIRE required` | Role insuffisant | Utiliser un profil avec le bon role |
| `Rate limited` | Trop de tentatives echouees | Attendre 1 minute |
| `Tool not found` | Tool non enregistre | Verifier que le cache est a jour (`cache:clear`) |
| `Error while executing tool` | Erreur interne | Verifier les logs et les parametres |

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,669 @@
# MCP Server — Inventory Project — Design Spec
**Date :** 2026-03-16
**Version projet :** 1.9.1
**Statut :** Draft (post-review v2)
---
## 1. Objectif
Exposer l'intégralité de l'API Inventory (machines, pièces, composants, produits, sites, constructeurs, custom fields, documents, commentaires, audit) via un serveur MCP (Model Context Protocol) intégré directement dans l'application Symfony.
Le serveur doit être compatible avec tous les clients MCP majeurs : Claude Code, Claude Desktop, ChatGPT Desktop, Codex, et tout client supportant le protocole MCP.
## 2. Contraintes
| Contrainte | Détail |
|---|---|
| **Réseau** | Machine hébergée sur un réseau fermé d'entreprise. Les clients distants (Claude Desktop, ChatGPT, Codex) accèdent via un tunnel chiffré (Cloudflare/WireGuard/SSH) |
| **Auth** | Pass-through : chaque client fournit ses propres credentials (profileId + password). Le serveur MCP charge le profil correspondant et applique ses rôles. Les actions sont traçables par utilisateur dans l'audit log |
| **Transport** | Dual : stdio pour usage local (Claude Code sur la même machine) + HTTP Streamable/SSE pour clients distants via tunnel |
| **Stack** | PHP / Symfony 8.0 — le serveur MCP vit dans l'application existante, pas de service séparé |
| **Scope** | Lecture + écriture complète — les outils couvrent tout le CRUD + les opérations métier |
## 3. Stack technique
| Composant | Choix |
|---|---|
| SDK MCP | `symfony/mcp-bundle` v0.6.0 + `mcp/sdk` ^0.4 (officiel Symfony + PHP Foundation + Anthropic) |
| Transport stdio | `bin/console mcp:server` (dans le container Docker) |
| Transport HTTP | Endpoint `/_mcp` sur le même port que l'API (8081) |
| Auth HTTP | Custom Symfony Authenticator (`McpHeaderAuthenticator`) intégré au firewall Symfony |
| Auth stdio | Token synthétique chargé depuis `$_ENV` au boot |
| Rate limiting | `symfony/rate-limiter` sur les tentatives d'auth échouées |
| Accès données | Repositories Doctrine directs (pas de hop HTTP interne) |
**Note :** Le bundle est expérimental et non couvert par la BC Promise de Symfony. L'implémentation inclut un spike/PoC initial (étape 1 du plan) pour valider la compatibilité de l'API réelle du bundle avec ce design.
## 4. Architecture
```
┌─────────────────────────────────────────────────────┐
│ Docker Compose (réseau fermé entreprise) │
│ │
│ ┌─────────────────────────────────────────────┐ │
│ │ php-inventory-apache (Symfony 8) │ │
│ │ │ │
│ │ /api/* ← API REST existante │ │
│ │ /_mcp ← Endpoint MCP HTTP (SSE) │ │
│ │ bin/console mcp:server ← Transport stdio │ │
│ │ │ │
│ │ Firewall Symfony : │ │
│ │ ^/api → SessionProfileAuthenticator │ │
│ │ ^/_mcp → McpHeaderAuthenticator │ │
│ │ │ │
│ │ src/Mcp/Tool/ ← Tools MCP │ │
│ │ src/Mcp/Resource/ ← Resources MCP │ │
│ │ src/Mcp/Security/ ← Authenticator + Guard │ │
│ └──────────┬───────────────────────────────────┘ │
│ │ réseau Docker interne │
│ ┌──────────▼──────────┐ │
│ │ PostgreSQL 16 │ │
│ └─────────────────────┘ │
└──────────────────┬──────────────────────────────────┘
│ tunnel (chiffré)
┌──────────────▼──────────────────┐
│ Postes utilisateurs │
│ - Claude Desktop → HTTP/SSE │
│ - ChatGPT Desktop → HTTP/SSE │
│ - Codex → HTTP/SSE │
│ - Claude Code local → stdio │
└─────────────────────────────────┘
```
Le serveur MCP accède directement aux repositories Doctrine et aux services Symfony existants. Pas de double sérialisation — les tools appellent les mêmes repositories/services que les controllers REST.
## 5. Authentification pass-through
### 5.1 Firewall Symfony — intégration sécurité
Un firewall dédié pour `/_mcp` avec un authenticator custom. Cela garantit que `$security->getUser()` retourne le bon Profile, que la hiérarchie des rôles fonctionne via `is_granted()`, et que l'audit log trace le bon acteur.
```yaml
# config/packages/security.yaml (ajout)
security:
firewalls:
mcp:
pattern: ^/_mcp
stateless: true
custom_authenticators:
- App\Mcp\Security\McpHeaderAuthenticator
```
Le `McpHeaderAuthenticator` implémente `AuthenticatorInterface` :
1. Extrait `X-Profile-Id` et `X-Profile-Password` des headers
2. Charge le profil via `ProfileRepository`
3. Vérifie le password hash via `UserPasswordHasherInterface`
4. Retourne un `Passport` avec le Profile comme User
5. Symfony gère le reste (token, rôles, hiérarchie)
Cela permet à `AbstractAuditSubscriber.resolveActorProfileId()` de résoudre l'acteur via `$security->getUser()` sans aucune modification du code existant.
### 5.2 Transport stdio — token synthétique
Pour le transport stdio (pas de requête HTTP), un `EventSubscriber` sur `console.command` (quand la commande est `mcp:server`) :
1. Lit `MCP_PROFILE_ID` et `MCP_PROFILE_PASSWORD` depuis `$_ENV`
2. Valide les credentials
3. Injecte un `UsernamePasswordToken` synthétique dans le `TokenStorage` avec le Profile
### 5.3 Rate limiting — protection brute-force
```yaml
# config/packages/rate_limiter.yaml
framework:
rate_limiter:
mcp_auth:
policy: sliding_window
limit: 5
interval: '1 minute'
```
Le `McpHeaderAuthenticator` consomme le rate limiter sur chaque tentative échouée (clé = IP). Après 5 échecs en 1 minute, toute tentative est rejetée avec une erreur MCP `429 Too Many Requests`.
### 5.4 Vérification des rôles
Chaque tool déclare un rôle minimum. L'authenticator Symfony gère la hiérarchie :
| Rôle | Droits MCP |
|---|---|
| `ROLE_VIEWER` | Tous les tools de lecture (list, get, search, history) |
| `ROLE_GESTIONNAIRE` | Lecture + écriture (create, update, delete, slots, clone) |
| `ROLE_ADMIN` | Tout + gestion profils |
Les tools utilisent `$this->security->isGranted('ROLE_XXX')` pour vérifier, bénéficiant de la hiérarchie Symfony standard.
## 6. Catalogue des Tools MCP
### 6.1 Tools de haut niveau (métier)
| Tool | Description | Paramètres principaux | Rôle min |
|---|---|---|---|
| `search_inventory` | Recherche globale dans toutes les entités (machines, pièces, composants, produits, sites, constructeurs) | `query: string`, `types?: string[]`, `limit?: int` | VIEWER |
| `get_machine_structure` | Hiérarchie complète d'une machine : composants, pièces, produits, custom fields, slots | `machineId: string` | VIEWER |
| `clone_machine` | Clone une machine avec sa structure complète | `machineId: string`, `name: string`, `siteId: string`, `reference?: string` | GESTIONNAIRE |
| `get_entity_history` | Historique d'audit d'une entité | `entityType: string`, `entityId: string` | VIEWER |
| `get_activity_log` | Journal d'activité global | `page?: int`, `limit?: int`, `entityType?: string`, `action?: string` | VIEWER |
| `get_dashboard_stats` | Compteurs globaux (machines, pièces, composants, produits, commentaires ouverts) | aucun | VIEWER |
| `sync_model_type` | Preview ou exécution de la synchronisation skeleton d'un ModelType | `modelTypeId: string`, `action: "preview"\|"sync"`, `structure?: object` | GESTIONNAIRE |
### 6.2 Tools CRUD — Machines
| Tool | Description | Rôle min |
|---|---|---|
| `list_machines` | Lister les machines avec filtres (nom, référence, site) et pagination | VIEWER |
| `get_machine` | Détail d'une machine par ID | VIEWER |
| `create_machine` | Créer une machine (nom, référence, siteId, constructeurs) | GESTIONNAIRE |
| `update_machine` | Mise à jour partielle d'une machine | GESTIONNAIRE |
| `delete_machine` | Supprimer une machine | GESTIONNAIRE |
### 6.3 Tools CRUD — Composants
| Tool | Description | Rôle min |
|---|---|---|
| `list_composants` | Lister les composants avec filtres et pagination | VIEWER |
| `get_composant` | Détail d'un composant par ID (incluant ses slots) | VIEWER |
| `create_composant` | Créer un composant (nom, référence, modelTypeId, constructeurs). Retourne l'ID + les slots vides auto-générés | GESTIONNAIRE |
| `update_composant` | Mise à jour partielle | GESTIONNAIRE |
| `delete_composant` | Supprimer un composant | GESTIONNAIRE |
### 6.4 Tools CRUD — Pièces
| Tool | Description | Rôle min |
|---|---|---|
| `list_pieces` | Lister les pièces avec filtres et pagination | VIEWER |
| `get_piece` | Détail d'une pièce par ID (incluant ses product-slots) | VIEWER |
| `create_piece` | Créer une pièce (nom, référence, modelTypeId, constructeurs). Retourne l'ID + product-slots auto-générés | GESTIONNAIRE |
| `update_piece` | Mise à jour partielle | GESTIONNAIRE |
| `delete_piece` | Supprimer une pièce | GESTIONNAIRE |
### 6.5 Tools CRUD — Produits
| Tool | Description | Rôle min |
|---|---|---|
| `list_products` | Lister les produits avec filtres et pagination | VIEWER |
| `get_product` | Détail d'un produit par ID | VIEWER |
| `create_product` | Créer un produit (nom, référence, modelTypeId, prix (string), constructeurs) | GESTIONNAIRE |
| `update_product` | Mise à jour partielle | GESTIONNAIRE |
| `delete_product` | Supprimer un produit | GESTIONNAIRE |
### 6.6 Tools CRUD — Sites
| Tool | Description | Rôle min |
|---|---|---|
| `list_sites` | Lister les sites | VIEWER |
| `get_site` | Détail d'un site par ID | VIEWER |
| `create_site` | Créer un site | GESTIONNAIRE |
| `update_site` | Mise à jour partielle | GESTIONNAIRE |
| `delete_site` | Supprimer un site | GESTIONNAIRE |
### 6.7 Tools CRUD — Constructeurs
| Tool | Description | Rôle min |
|---|---|---|
| `list_constructeurs` | Lister les constructeurs/fournisseurs | VIEWER |
| `get_constructeur` | Détail d'un constructeur par ID | VIEWER |
| `create_constructeur` | Créer un constructeur | GESTIONNAIRE |
| `update_constructeur` | Mise à jour partielle | GESTIONNAIRE |
| `delete_constructeur` | Supprimer un constructeur | GESTIONNAIRE |
### 6.8 Tools — Commentaires (splittés)
| Tool | Description | Rôle min |
|---|---|---|
| `list_comments` | Lister les commentaires d'une entité | VIEWER |
| `create_comment` | Créer un commentaire sur une entité | VIEWER |
| `resolve_comment` | Marquer un commentaire comme résolu | GESTIONNAIRE |
| `get_unresolved_comments_count` | Nombre de commentaires non résolus | VIEWER |
### 6.9 Tools — Custom Fields (splittés)
| Tool | Description | Rôle min |
|---|---|---|
| `list_custom_field_values` | Lister les custom field values d'une entité | VIEWER |
| `upsert_custom_field_values` | Créer ou mettre à jour des custom field values | GESTIONNAIRE |
| `delete_custom_field_value` | Supprimer une custom field value | GESTIONNAIRE |
### 6.10 Tools — Documents (splittés)
| Tool | Description | Rôle min |
|---|---|---|
| `list_documents` | Lister les documents d'une entité | VIEWER |
| `delete_document` | Supprimer un document | GESTIONNAIRE |
> **Limitation connue :** L'upload de documents n'est pas supporté via MCP. Le protocole MCP échange du JSON — l'upload de fichiers binaires (multipart/form-data) n'est pas compatible. Les uploads doivent se faire via l'API REST `/api/documents` (POST multipart). Cette limitation pourra être réévaluée si le protocole MCP ajoute un support binaire.
### 6.11 Tools — Machine Links (splittés)
| Tool | Description | Rôle min |
|---|---|---|
| `list_machine_links` | Lister les liens composant/pièce/produit d'une machine | VIEWER |
| `add_machine_links` | Ajouter des liens machine↔composant/pièce/produit | GESTIONNAIRE |
| `update_machine_link` | Modifier un lien (quantité, overrides) | GESTIONNAIRE |
| `remove_machine_link` | Supprimer un lien | GESTIONNAIRE |
### 6.12 Tools — Slots
| Tool | Description | Rôle min |
|---|---|---|
| `list_slots` | Lister les slots d'un composant ou pièce avec état (rempli/vide, requirement). Paramètre `entityType: "composant"\|"piece"` + `entityId` | VIEWER |
| `update_slots` | Remplir un ou plusieurs slots. Paramètre `slots: [{slotId, selectedPieceId?\|selectedProductId?\|selectedComposantId?}]` | GESTIONNAIRE |
> **Note :** Un seul tool `list_slots` et un seul `update_slots` — ils acceptent un paramètre `entityType` pour dispatcher vers composant ou pièce. Un seul fichier d'implémentation par tool.
### 6.13 Tools — ModelTypes
| Tool | Description | Rôle min |
|---|---|---|
| `list_model_types` | Lister les ModelTypes par catégorie avec skeleton requirements | VIEWER |
| `get_model_type` | Détail complet d'un ModelType (requirements + custom fields) | VIEWER |
| `create_model_type` | Créer un ModelType | GESTIONNAIRE |
| `update_model_type` | Modifier un ModelType | GESTIONNAIRE |
| `delete_model_type` | Supprimer un ModelType | GESTIONNAIRE |
**Total : ~55 tools** (splittés pour des schémas JSON non-ambigus, meilleure compatibilité LLM)
> **Note :** Les tools d'administration des profils (`list_profiles`, `create_profile`, etc.) ne sont pas inclus — la gestion des profils reste exclusivement via l'API REST `/api/admin/profiles` (ROLE_ADMIN). Cela évite d'exposer la gestion des comptes/mots de passe via MCP.
## 7. Resources MCP
| URI | Description | Contenu |
|---|---|---|
| `inventory://schema/entities` | Schéma de toutes les entités | Nom, champs (nom, type, nullable, description) pour chaque entité |
| `inventory://model-types/{category}` | ModelTypes par catégorie | Liste des ModelTypes avec leurs skeleton requirements et custom fields |
| `inventory://roles` | Hiérarchie des rôles | Rôles et permissions associées pour guider le LLM |
| `inventory://stats` | Statistiques globales | Compteurs de chaque entité, commentaires ouverts |
## 8. Workflows de création guidés
### 8.1 Créer un Composant complet
```
1. list_model_types(category: "composant")
→ Choisir le type de composant
2. get_model_type(modelTypeId)
→ Voir les skeleton requirements : pièces, produits, sous-composants attendus
→ Voir les custom fields de chaque requirement
3. create_composant(name, reference, modelTypeId, constructeurs)
→ Reçoit: { id, slots: [{slotId, type, requirementName}, ...] }
4. search_inventory(query: "Roulement", types: ["piece"])
→ Trouver les pièces candidates pour chaque slot
5. update_slots([{slotId, selectedPieceId}, {slotId, selectedProductId}, ...])
→ Remplir les slots
6. upsert_custom_field_values(entityType: "composant", entityId,
fields: [{name: "Tension", value: "220V"}, ...])
→ Remplir les custom fields
```
### 8.2 Créer une Pièce complète
```
1. list_model_types(category: "piece")
2. get_model_type(modelTypeId)
3. create_piece(name, reference, modelTypeId, constructeurs)
→ Reçoit: { id, productSlots: [{slotId, requirementName}, ...] }
4. search_inventory(query: "...", types: ["product"])
5. update_slots([{slotId, selectedProductId}, ...])
6. upsert_custom_field_values(...)
```
### 8.3 Créer un Produit
```
1. list_model_types(category: "product")
2. create_product(name, reference, modelTypeId, prix, constructeurs)
3. upsert_custom_field_values(...)
```
### 8.4 Créer une Machine complète (de bas en haut)
```
1. Créer les produits nécessaires (§8.3)
2. Créer les pièces avec les produits dans les slots (§8.2)
3. Créer les composants avec les pièces dans les slots (§8.1)
4. list_sites → choisir le site
5. create_machine(name, reference, siteId, constructeurs)
6. add_machine_links(machineId, links: [
{type: "composant", entityId, quantity},
{type: "piece", entityId, quantity},
{type: "product", entityId}
])
7. upsert_custom_field_values(entityType: "machine", machineId, ...)
```
## 9. Pagination
Toutes les tools `list_*` utilisent un contrat de pagination uniforme :
### Paramètres d'entrée
| Paramètre | Type | Default | Description |
|---|---|---|---|
| `page` | int | 1 | Numéro de page (1-indexed) |
| `limit` | int | 30 | Nombre d'items par page (max 100) |
### Format de réponse
```json
{
"items": [...],
"total": 142,
"page": 1,
"limit": 30,
"pageCount": 5
}
```
## 10. Format des erreurs
Toutes les erreurs MCP suivent un format uniforme via `isError: true` dans la réponse tool :
```json
{
"isError": true,
"content": [{"type": "text", "text": "Permission denied: ROLE_GESTIONNAIRE required for create_machine"}]
}
```
### Catégories d'erreurs
| Code | Description | Exemple |
|---|---|---|
| `auth_error` | Credentials invalides ou manquants | "Authentication failed: invalid password" |
| `permission_denied` | Rôle insuffisant pour l'opération | "Permission denied: ROLE_GESTIONNAIRE required" |
| `not_found` | Entité introuvable | "Machine not found: cl4a8b..." |
| `validation_error` | Données invalides | "Validation failed: name is required" |
| `rate_limited` | Trop de tentatives d'auth échouées | "Rate limited: try again in 45 seconds" |
| `internal_error` | Erreur serveur inattendue | "Internal error: database connection failed" |
Le champ `text` inclut toujours la catégorie en préfixe pour que le LLM puisse adapter son comportement.
## 11. Configuration
### 11.1 Symfony — config/packages/mcp.yaml
```yaml
mcp:
app: 'inventory'
version: '%env(file:resolve:VERSION)%'
description: 'Inventory MCP Server - Gestion inventaire industriel (machines, pièces, composants, produits)'
instructions: |
Serveur MCP pour gérer un inventaire industriel.
Entités principales : Machine, Composant, Pièce, Produit, Site, Constructeur.
Utilisez search_inventory pour chercher dans toutes les entités.
Utilisez get_model_type pour comprendre la structure attendue avant de créer un composant ou une pièce.
Consultez la resource inventory://schema/entities pour voir le schéma complet.
Authentification requise : envoyez X-Profile-Id et X-Profile-Password dans les headers HTTP.
client_transports:
stdio: true
http: true
http:
path: /_mcp
session:
store: file
directory: '%kernel.cache_dir%/mcp-sessions'
ttl: 3600
```
### 11.2 Security — config/packages/security.yaml (ajout firewall)
```yaml
security:
firewalls:
# AVANT le firewall api existant
mcp:
pattern: ^/_mcp
stateless: true
custom_authenticators:
- App\Mcp\Security\McpHeaderAuthenticator
api:
pattern: ^/api
# ... existant ...
```
### 11.3 Rate Limiter — config/packages/rate_limiter.yaml
```yaml
framework:
rate_limiter:
mcp_auth:
policy: sliding_window
limit: 5
interval: '1 minute'
```
### 11.4 Routes — config/routes.yaml (ajout)
```yaml
mcp:
resource: .
type: mcp
```
### 11.5 Logging — config/packages/monolog.yaml (ajout)
```yaml
monolog:
channels: ['mcp']
handlers:
mcp:
type: rotating_file
path: '%kernel.logs_dir%/mcp.log'
level: info
channels: ['mcp']
max_files: 30
```
## 12. Configuration des clients
### 12.1 Claude Code (local, stdio via Docker)
Fichier `.mcp.json` à la racine du projet :
```json
{
"mcpServers": {
"inventory": {
"command": "docker",
"args": [
"exec", "-i",
"-e", "MCP_PROFILE_ID=<votre-profile-id>",
"-e", "MCP_PROFILE_PASSWORD=<votre-password>",
"php-inventory-apache",
"php", "bin/console", "mcp:server"
]
}
}
}
```
> **Note :** Les env vars sont passées via les flags `-e` de `docker exec` car le bloc `env` de `.mcp.json` ne les injecte pas dans le container Docker. Si PHP et les dépendances Composer sont disponibles directement sur l'hôte (hors Docker), on peut utiliser `"command": "php", "args": ["bin/console", "mcp:server"]` avec un bloc `env` standard.
### 12.2 Claude Desktop (distant, HTTP via tunnel)
Fichier `claude_desktop_config.json` :
```json
{
"mcpServers": {
"inventory": {
"url": "https://inventory.company-tunnel.com/_mcp",
"headers": {
"X-Profile-Id": "<votre-profile-id>",
"X-Profile-Password": "<votre-password>"
}
}
}
}
```
### 12.3 ChatGPT Desktop (HTTP via tunnel)
Même principe HTTP : URL du tunnel + headers d'auth. Format de config selon la doc ChatGPT MCP.
### 12.4 Codex (HTTP via tunnel)
Même config HTTP que Claude Desktop.
## 13. Structure des fichiers
```
src/
└── Mcp/
├── Tool/
│ ├── SearchInventoryTool.php # search_inventory
│ ├── DashboardStatsTool.php # get_dashboard_stats
│ ├── ActivityLogTool.php # get_activity_log
│ ├── EntityHistoryTool.php # get_entity_history
│ ├── Machine/
│ │ ├── ListMachinesTool.php # list_machines
│ │ ├── GetMachineTool.php # get_machine
│ │ ├── CreateMachineTool.php # create_machine
│ │ ├── UpdateMachineTool.php # update_machine
│ │ ├── DeleteMachineTool.php # delete_machine
│ │ ├── MachineStructureTool.php # get_machine_structure
│ │ ├── CloneMachineTool.php # clone_machine
│ │ ├── ListMachineLinksTool.php # list_machine_links
│ │ ├── AddMachineLinksTool.php # add_machine_links
│ │ ├── UpdateMachineLinkTool.php # update_machine_link
│ │ └── RemoveMachineLinkTool.php # remove_machine_link
│ ├── Composant/
│ │ ├── ListComposantsTool.php # list_composants
│ │ ├── GetComposantTool.php # get_composant
│ │ ├── CreateComposantTool.php # create_composant
│ │ ├── UpdateComposantTool.php # update_composant
│ │ └── DeleteComposantTool.php # delete_composant
│ ├── Piece/
│ │ ├── ListPiecesTool.php # list_pieces
│ │ ├── GetPieceTool.php # get_piece
│ │ ├── CreatePieceTool.php # create_piece
│ │ ├── UpdatePieceTool.php # update_piece
│ │ └── DeletePieceTool.php # delete_piece
│ ├── Slot/
│ │ ├── ListSlotsTool.php # list_slots (dispatche par entityType)
│ │ └── UpdateSlotsTool.php # update_slots
│ ├── Product/
│ │ ├── ListProductsTool.php # list_products
│ │ ├── GetProductTool.php # get_product
│ │ ├── CreateProductTool.php # create_product
│ │ ├── UpdateProductTool.php # update_product
│ │ └── DeleteProductTool.php # delete_product
│ ├── Site/
│ │ ├── ListSitesTool.php # list_sites
│ │ ├── GetSiteTool.php # get_site
│ │ ├── CreateSiteTool.php # create_site
│ │ ├── UpdateSiteTool.php # update_site
│ │ └── DeleteSiteTool.php # delete_site
│ ├── Constructeur/
│ │ ├── ListConstructeursTool.php # list_constructeurs
│ │ ├── GetConstructeurTool.php # get_constructeur
│ │ ├── CreateConstructeurTool.php # create_constructeur
│ │ ├── UpdateConstructeurTool.php # update_constructeur
│ │ └── DeleteConstructeurTool.php # delete_constructeur
│ ├── ModelType/
│ │ ├── ListModelTypesTool.php # list_model_types
│ │ ├── GetModelTypeTool.php # get_model_type
│ │ ├── CreateModelTypeTool.php # create_model_type
│ │ ├── UpdateModelTypeTool.php # update_model_type
│ │ ├── DeleteModelTypeTool.php # delete_model_type
│ │ └── SyncModelTypeTool.php # sync_model_type
│ ├── CustomField/
│ │ ├── ListCustomFieldValuesTool.php # list_custom_field_values
│ │ ├── UpsertCustomFieldValuesTool.php # upsert_custom_field_values
│ │ └── DeleteCustomFieldValueTool.php # delete_custom_field_value
│ ├── Document/
│ │ ├── ListDocumentsTool.php # list_documents
│ │ └── DeleteDocumentTool.php # delete_document
│ └── Comment/
│ ├── ListCommentsTool.php # list_comments
│ ├── CreateCommentTool.php # create_comment
│ ├── ResolveCommentTool.php # resolve_comment
│ └── UnresolvedCountTool.php # get_unresolved_comments_count
├── Resource/
│ ├── SchemaResource.php # inventory://schema/entities
│ ├── ModelTypesResource.php # inventory://model-types/{category}
│ ├── RolesResource.php # inventory://roles
│ └── StatsResource.php # inventory://stats
└── Security/
└── McpHeaderAuthenticator.php # Symfony Authenticator pour firewall MCP
docs/
└── mcp/
└── README.md # Guide utilisateur complet
```
## 14. Documentation utilisateur (docs/mcp/README.md)
Le guide contiendra :
1. **Introduction** — Qu'est-ce que le MCP Inventory, à quoi ça sert, quels clients sont supportés
2. **Prérequis** — Profil avec rôle suffisant, accès au tunnel, client MCP compatible
3. **Installation & configuration par client** — Exemples copier-coller pour :
- Claude Code (stdio via Docker)
- Claude Desktop (HTTP via tunnel)
- ChatGPT Desktop (HTTP via tunnel)
- Codex (HTTP via tunnel)
4. **Catalogue des tools** — Tableau complet avec nom, description, paramètres, rôle requis
5. **Workflows guidés** — Comment créer une machine, un composant, une pièce, un produit (étape par étape avec exemples d'appels)
6. **Resources disponibles** — URIs et contenu exposé
7. **Rôles & permissions** — Quel rôle permet quelles actions
8. **Format des erreurs** — Catégories et exemples
9. **Limitations connues** — Upload documents non supporté via MCP
10. **Troubleshooting** — Erreurs courantes (auth failed, tunnel down, rôle insuffisant, rate limited)
## 15. Sécurité
| Mesure | Détail |
|---|---|
| **Firewall Symfony** | `/_mcp` a son propre firewall avec `McpHeaderAuthenticator` — intégré au système de sécurité standard |
| **Vérification rôle** | Chaque tool vérifie via `$security->isGranted()` avec hiérarchie des rôles |
| **Audit trail** | `AbstractAuditSubscriber.resolveActorProfileId()` fonctionne nativement car `$security->getUser()` retourne le Profile authentifié |
| **Rate limiting** | 5 tentatives d'auth échouées par minute par IP → rejet |
| **Transport chiffré** | Le tunnel assure le chiffrement en transit pour les clients distants |
| **Pas de secrets dans le code** | Credentials dans env vars (stdio) ou headers (HTTP), jamais en dur |
| **Sessions MCP** | TTL 1h, stockage fichier, nettoyage automatique |
| **CORS** | Non nécessaire — les clients MCP sont des apps natives (pas des navigateurs). Le tunnel termine la connexion côté serveur. À réévaluer si un client browser-based apparaît |
## 16. Backward Compatibility
Les tools MCP suivent une politique additive :
- **Ajouts** : nouveaux tools, nouveaux paramètres optionnels → toujours OK
- **Suppressions** : marquer un tool comme deprecated pendant 1 version avant suppression
- **Breaking changes** : changer le type/nom d'un paramètre requis → bumper la version MCP
Le champ `version` dans la config MCP (lu depuis `VERSION`) signale les changements.
## 17. Dépendances à installer
```bash
composer require symfony/mcp-bundle symfony/rate-limiter
```
Le bundle tire `mcp/sdk` automatiquement.
## 18. Tests
Les tools MCP seront testés via :
- **Tests unitaires** : chaque tool testé avec des mocks de repositories, vérification des paramètres et des réponses
- **Tests d'intégration** : appels MCP stdio via `docker exec ... php bin/console mcp:server` avec des fixtures
- **Tests de sécurité** : vérification que les tools rejettent les appels sans auth, avec rôle insuffisant, et après rate limiting
- Pattern : hériter de `AbstractApiTestCase` pour réutiliser les factories existantes (`createProfile()`, `createMachine()`, etc.)
## 19. Spike / PoC initial
Avant l'implémentation complète, une étape de validation :
1. Installer `symfony/mcp-bundle` dans le projet
2. Créer un tool minimal (`get_dashboard_stats`) avec l'attribut `#[McpTool]`
3. Tester le transport stdio : `docker exec -i php-inventory-apache php bin/console mcp:server`
4. Tester le transport HTTP : appel POST sur `/_mcp`
5. Valider que l'authenticator custom fonctionne avec le firewall
6. Confirmer que `$security->getUser()` retourne le bon Profile dans un tool
Si le PoC révèle des incompatibilités avec l'API du bundle, adapter le design avant de continuer.

View File

@@ -20,7 +20,6 @@
<testsuites>
<testsuite name="Project Test Suite">
<directory>tests</directory>
<exclude>tests/Mcp</exclude>
</testsuite>
</testsuites>

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Resource;
use Mcp\Capability\Attribute\McpResource;
use Mcp\Schema\Content\TextContent;
#[McpResource(
uri: 'inventory://roles',
name: 'Roles & Permissions',
description: 'Role hierarchy and permissions for MCP tools.',
mimeType: 'application/json'
)]
class RolesResource
{
public function __invoke(): array
{
$roles = [
'hierarchy' => [
'ROLE_ADMIN' => 'Inherits ROLE_GESTIONNAIRE. Can manage profiles.',
'ROLE_GESTIONNAIRE' => 'Inherits ROLE_VIEWER. Can create, update, delete all entities.',
'ROLE_VIEWER' => 'Inherits ROLE_USER. Can read all entities, create comments, search.',
'ROLE_USER' => 'Base role. Authenticated but minimal access.',
],
'tool_permissions' => [
'ROLE_VIEWER' => 'list_*, get_*, search_inventory, get_dashboard_stats, get_entity_history, get_activity_log, list_comments, create_comment, get_unresolved_comments_count, list_custom_field_values, list_documents, list_slots',
'ROLE_GESTIONNAIRE' => 'All VIEWER tools + create_*, update_*, delete_*, clone_machine, update_slots, add_machine_links, remove_machine_link, resolve_comment, upsert_custom_field_values, sync_model_type',
],
];
return [new TextContent(text: json_encode($roles, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE))];
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Resource;
use Mcp\Capability\Attribute\McpResource;
use Mcp\Schema\Content\TextContent;
#[McpResource(
uri: 'inventory://schema/entities',
name: 'Entity Schema',
description: 'Complete schema of all inventory entities with their fields, types, and relationships.',
mimeType: 'application/json'
)]
class SchemaResource
{
public function __invoke(): array
{
$schema = [
'Machine' => [
'fields' => ['id (string)', 'name (string, unique)', 'reference (string?)', 'prix (string?)', 'createdAt', 'updatedAt'],
'relationships' => ['site (Site, required)', 'constructeurs (Constructeur[])', 'componentLinks (MachineComponentLink[])', 'pieceLinks (MachinePieceLink[])', 'productLinks (MachineProductLink[])', 'customFields (CustomField[])', 'customFieldValues (CustomFieldValue[])'],
],
'Composant' => [
'fields' => ['id (string)', 'name (string, unique)', 'reference (string?)', 'description (text?)', 'prix (string?)', 'createdAt', 'updatedAt'],
'relationships' => ['typeComposant (ModelType?)', 'constructeurs (Constructeur[])', 'pieceSlots (ComposantPieceSlot[])', 'productSlots (ComposantProductSlot[])', 'subcomponentSlots (ComposantSubcomponentSlot[])', 'customFieldValues (CustomFieldValue[])'],
],
'Piece' => [
'fields' => ['id (string)', 'name (string)', 'reference (string?, unique)', 'description (text?)', 'prix (string?)', 'createdAt', 'updatedAt'],
'relationships' => ['typePiece (ModelType?)', 'product (Product?)', 'constructeurs (Constructeur[])', 'productSlots (PieceProductSlot[])', 'customFieldValues (CustomFieldValue[])'],
],
'Product' => [
'fields' => ['id (string)', 'name (string, unique)', 'reference (string?)', 'supplierPrice (string?)', 'createdAt', 'updatedAt'],
'relationships' => ['typeProduct (ModelType?)', 'constructeurs (Constructeur[])'],
],
'Site' => [
'fields' => ['id (string)', 'name (string)', 'contactName (string)', 'contactPhone (string)', 'contactAddress (string)', 'contactPostalCode (string)', 'contactCity (string)', 'color (string)', 'createdAt', 'updatedAt'],
'relationships' => ['machines (Machine[])'],
],
'Constructeur' => [
'fields' => ['id (string)', 'name (string, unique)', 'email (string?)', 'phone (string?)', 'createdAt', 'updatedAt'],
'relationships' => ['machines (Machine[])', 'composants (Composant[])', 'pieces (Piece[])', 'products (Product[])'],
],
'ModelType' => [
'fields' => ['id (string)', 'name (string)', 'category (machine|composant|piece|product)', 'code (string?)', 'createdAt', 'updatedAt'],
'relationships' => ['skeletonPieceRequirements[]', 'skeletonProductRequirements[]', 'skeletonSubcomponentRequirements[]'],
],
];
return [new TextContent(text: json_encode($schema, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE))];
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Resource;
use App\Repository\ComposantRepository;
use App\Repository\MachineRepository;
use App\Repository\PieceRepository;
use App\Repository\ProductRepository;
use App\Repository\SiteRepository;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpResource;
use Mcp\Schema\Content\TextContent;
#[McpResource(
uri: 'inventory://stats',
name: 'Inventory Statistics',
description: 'Global counters: machines, pieces, composants, products, sites, unresolved comments.',
mimeType: 'application/json'
)]
class StatsResource
{
public function __construct(
private readonly MachineRepository $machines,
private readonly PieceRepository $pieces,
private readonly ComposantRepository $composants,
private readonly ProductRepository $products,
private readonly SiteRepository $sites,
private readonly EntityManagerInterface $em,
) {}
public function __invoke(): array
{
$unresolvedComments = (int) $this->em->createQuery(
"SELECT COUNT(c.id) FROM App\\Entity\\Comment c WHERE c.status = 'open'"
)->getSingleScalarResult();
return [new TextContent(text: json_encode([
'machines' => $this->machines->count([]),
'pieces' => $this->pieces->count([]),
'composants' => $this->composants->count([]),
'products' => $this->products->count([]),
'sites' => $this->sites->count([]),
'unresolvedComments' => $unresolvedComments,
], JSON_THROW_ON_ERROR))];
}
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Security;
use App\Repository\ProfileRepository;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\ConsoleEvents;
use Symfony\Component\Console\Event\ConsoleCommandEvent;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
#[AsEventListener(event: ConsoleEvents::COMMAND)]
final class McpStdioAuthSubscriber
{
public function __construct(
private readonly ProfileRepository $profiles,
private readonly UserPasswordHasherInterface $passwordHasher,
private readonly TokenStorageInterface $tokenStorage,
private readonly LoggerInterface $logger,
) {}
public function __invoke(ConsoleCommandEvent $event): void
{
$command = $event->getCommand();
if (!$command || !str_starts_with($command->getName() ?? '', 'mcp:')) {
return;
}
$profileId = $_ENV['MCP_PROFILE_ID'] ?? '';
$password = $_ENV['MCP_PROFILE_PASSWORD'] ?? '';
if ('' === $profileId || '' === $password) {
$this->logger->error('MCP stdio: missing MCP_PROFILE_ID or MCP_PROFILE_PASSWORD env vars');
$event->disableCommand();
$event->getOutput()->writeln('<error>MCP auth: MCP_PROFILE_ID and MCP_PROFILE_PASSWORD env vars required</error>');
return;
}
$profile = $this->profiles->find($profileId);
if (!$profile || !$profile->isActive()) {
$this->logger->error('MCP stdio: profile not found or inactive', ['profileId' => $profileId]);
$event->disableCommand();
$event->getOutput()->writeln('<error>MCP auth: invalid profile</error>');
return;
}
if (!$this->passwordHasher->isPasswordValid($profile, $password)) {
$this->logger->error('MCP stdio: invalid password', ['profileId' => $profileId]);
$event->disableCommand();
$event->getOutput()->writeln('<error>MCP auth: invalid password</error>');
return;
}
$token = new UsernamePasswordToken($profile, 'mcp', $profile->getRoles());
$this->tokenStorage->setToken($token);
$this->logger->info('MCP stdio auth success', [
'profileId' => $profileId,
'roles' => $profile->getRoles(),
]);
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool;
use App\Repository\AuditLogRepository;
use DateTimeInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
#[McpTool(
name: 'get_activity_log',
description: 'Get the global activity log with optional filters. Returns paginated audit entries across all entities.',
)]
class ActivityLogTool
{
use McpToolHelper;
public function __construct(
private readonly AuditLogRepository $auditLogs,
private readonly Security $security,
) {}
public function __invoke(int $page = 1, int $limit = 30, string $entityType = '', string $action = ''): array
{
$this->requireRole($this->security, 'ROLE_VIEWER');
$p = $this->paginationParams($page, $limit);
$filters = [];
if ('' !== $entityType) {
$filters['entityType'] = $entityType;
}
if ('' !== $action) {
$filters['action'] = $action;
}
$result = $this->auditLogs->findAllPaginated($p['page'], $p['limit'], $filters);
$items = array_map(
static function ($log) {
$snapshot = $log->getSnapshot();
return [
'id' => $log->getId(),
'entityType' => $log->getEntityType(),
'entityId' => $log->getEntityId(),
'entityName' => $snapshot['name'] ?? null,
'action' => $log->getAction(),
'diff' => $log->getDiff(),
'actorProfileId' => $log->getActorProfileId(),
'createdAt' => $log->getCreatedAt()->format(DateTimeInterface::ATOM),
];
},
$result['items'],
);
return $this->paginatedResponse(array_values($items), $result['total'], $p['page'], $p['limit']);
}
}

View File

@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Comment;
use App\Entity\Comment;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\ProfileRepository;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
#[McpTool(
name: 'create_comment',
description: 'Create a comment on an entity (machine, piece, composant, product…). Requires ROLE_VIEWER.',
)]
class CreateCommentTool
{
use McpToolHelper;
public function __construct(
private readonly EntityManagerInterface $em,
private readonly Security $security,
private readonly ProfileRepository $profiles,
) {}
public function __invoke(
string $content,
string $entityType,
string $entityId,
string $entityName = '',
): array {
$this->requireRole($this->security, 'ROLE_VIEWER');
$content = trim($content);
if ('' === $content) {
$this->mcpError('Validation', 'Le contenu est requis.');
}
$allowedTypes = ['machine', 'piece', 'composant', 'product', 'piece_category', 'component_category', 'product_category', 'machine_skeleton'];
if (!in_array($entityType, $allowedTypes, true)) {
$this->mcpError('Validation', "Type d'entité invalide : {$entityType}.");
}
$entityId = trim($entityId);
if ('' === $entityId) {
$this->mcpError('Validation', "L'identifiant de l'entité est requis.");
}
$user = $this->security->getUser();
$profile = $user ? $this->profiles->find($user->getUserIdentifier()) : null;
$authorName = 'Inconnu';
$authorId = '';
if ($profile) {
$authorId = $profile->getId();
$authorName = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName()));
if ('' === $authorName) {
$authorName = $profile->getEmail() ?? 'Inconnu';
}
}
$comment = new Comment();
$comment->setContent($content);
$comment->setEntityType($entityType);
$comment->setEntityId($entityId);
$comment->setEntityName('' !== $entityName ? $entityName : null);
$comment->setAuthorId($authorId);
$comment->setAuthorName($authorName);
$this->em->persist($comment);
$this->em->flush();
return $this->jsonResponse([
'id' => $comment->getId(),
'content' => $comment->getContent(),
'entityType' => $comment->getEntityType(),
'entityId' => $comment->getEntityId(),
'entityName' => $comment->getEntityName(),
'authorName' => $comment->getAuthorName(),
'status' => $comment->getStatus(),
]);
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Comment;
use App\Entity\Comment;
use App\Mcp\Tool\McpToolHelper;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
#[McpTool(
name: 'list_comments',
description: 'List comments for a given entity (machine, piece, composant, product…) with pagination. Filter by entityType and entityId.',
)]
class ListCommentsTool
{
use McpToolHelper;
public function __construct(
private readonly EntityManagerInterface $em,
) {}
public function __invoke(string $entityType, string $entityId, int $page = 1, int $limit = 30): array
{
$p = $this->paginationParams($page, $limit);
$repo = $this->em->getRepository(Comment::class);
$total = (int) $repo->createQueryBuilder('c')
->select('COUNT(c.id)')
->andWhere('c.entityType = :entityType')
->andWhere('c.entityId = :entityId')
->setParameter('entityType', $entityType)
->setParameter('entityId', $entityId)
->getQuery()
->getSingleScalarResult()
;
$items = $repo->createQueryBuilder('c')
->select(
'c.id',
'c.content',
'c.entityType',
'c.entityId',
'c.entityName',
'c.authorName',
'c.authorId',
'c.status',
'c.resolvedByName',
'c.resolvedAt',
'c.createdAt',
)
->andWhere('c.entityType = :entityType')
->andWhere('c.entityId = :entityId')
->setParameter('entityType', $entityType)
->setParameter('entityId', $entityId)
->orderBy('c.createdAt', 'DESC')
->setFirstResult($p['offset'])
->setMaxResults($p['limit'])
->getQuery()
->getArrayResult()
;
return $this->paginatedResponse($items, $total, $p['page'], $p['limit']);
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Comment;
use App\Entity\Comment;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\ProfileRepository;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
#[McpTool(
name: 'resolve_comment',
description: 'Mark a comment as resolved. Sets status to "resolved" with resolver info. Requires ROLE_GESTIONNAIRE.',
)]
class ResolveCommentTool
{
use McpToolHelper;
public function __construct(
private readonly EntityManagerInterface $em,
private readonly Security $security,
private readonly ProfileRepository $profiles,
) {}
public function __invoke(string $commentId): array
{
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
$comment = $this->em->getRepository(Comment::class)->find($commentId);
if (!$comment) {
$this->mcpError('NotFound', 'Commentaire introuvable.');
}
$user = $this->security->getUser();
$profile = $user ? $this->profiles->find($user->getUserIdentifier()) : null;
$resolverName = 'Inconnu';
$resolverId = null;
if ($profile) {
$resolverId = $profile->getId();
$resolverName = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName()));
if ('' === $resolverName) {
$resolverName = $profile->getEmail() ?? 'Inconnu';
}
}
$comment->setStatus('resolved');
$comment->setResolvedById($resolverId);
$comment->setResolvedByName($resolverName);
$comment->setResolvedAt(new DateTimeImmutable());
$this->em->flush();
return $this->jsonResponse([
'id' => $comment->getId(),
'status' => $comment->getStatus(),
'resolvedById' => $comment->getResolvedById(),
'resolvedByName' => $comment->getResolvedByName(),
]);
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Comment;
use App\Entity\Comment;
use App\Mcp\Tool\McpToolHelper;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
#[McpTool(
name: 'get_unresolved_comments_count',
description: 'Get the total count of unresolved (open) comments across all entities.',
)]
class UnresolvedCountTool
{
use McpToolHelper;
public function __construct(
private readonly EntityManagerInterface $em,
) {}
public function __invoke(): array
{
$count = (int) $this->em->getRepository(Comment::class)
->createQueryBuilder('c')
->select('COUNT(c.id)')
->andWhere('c.status = :status')
->setParameter('status', 'open')
->getQuery()
->getSingleScalarResult()
;
return $this->jsonResponse(['count' => $count]);
}
}

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Composant;
use App\Entity\Composant;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\ConstructeurRepository;
use App\Repository\ModelTypeRepository;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
#[McpTool(
name: 'create_composant',
description: 'Create a new composant. prix must be a string (e.g. "12.50"). Requires ROLE_GESTIONNAIRE.',
)]
class CreateComposantTool
{
use McpToolHelper;
public function __construct(
private readonly EntityManagerInterface $em,
private readonly Security $security,
private readonly ModelTypeRepository $modelTypes,
private readonly ConstructeurRepository $constructeurs,
) {}
/**
* @param string[] $constructeurIds
*/
public function __invoke(
string $name,
string $reference = '',
string $description = '',
string $prix = '',
string $modelTypeId = '',
array $constructeurIds = [],
): array {
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
$composant = new Composant();
$composant->setName($name);
if ('' !== $reference) {
$composant->setReference($reference);
}
if ('' !== $description) {
$composant->setDescription($description);
}
if ('' !== $prix) {
$composant->setPrix($prix);
}
if ('' !== $modelTypeId) {
$modelType = $this->modelTypes->find($modelTypeId);
if (!$modelType) {
$this->mcpError('not_found', "ModelType not found: {$modelTypeId}");
}
$composant->setTypeComposant($modelType);
}
foreach ($constructeurIds as $cId) {
$c = $this->constructeurs->find($cId);
if (!$c) {
$this->mcpError('not_found', "Constructeur not found: {$cId}");
}
$composant->addConstructeur($c);
}
$this->em->persist($composant);
$this->em->flush();
return $this->jsonResponse([
'id' => $composant->getId(),
'name' => $composant->getName(),
]);
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Composant;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\ComposantRepository;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
#[McpTool(
name: 'delete_composant',
description: 'Delete a composant by ID. Requires ROLE_GESTIONNAIRE.',
)]
class DeleteComposantTool
{
use McpToolHelper;
public function __construct(
private readonly ComposantRepository $composants,
private readonly EntityManagerInterface $em,
private readonly Security $security,
) {}
public function __invoke(string $composantId): array
{
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
$composant = $this->composants->find($composantId);
if (!$composant) {
$this->mcpError('not_found', "Composant not found: {$composantId}");
}
$this->em->remove($composant);
$this->em->flush();
return $this->jsonResponse(['deleted' => true, 'id' => $composantId]);
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Composant;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\ComposantRepository;
use Mcp\Capability\Attribute\McpTool;
#[McpTool(
name: 'get_composant',
description: 'Get a single composant by ID with all its details, including typeComposant and constructeurs.',
)]
class GetComposantTool
{
use McpToolHelper;
public function __construct(
private readonly ComposantRepository $composants,
) {}
public function __invoke(string $composantId): array
{
$composant = $this->composants->find($composantId);
if (!$composant) {
$this->mcpError('not_found', "Composant not found: {$composantId}");
}
$constructeurs = [];
foreach ($composant->getConstructeurs() as $c) {
$constructeurs[] = [
'id' => $c->getId(),
'name' => $c->getName(),
];
}
$typeComposant = null;
if ($composant->getTypeComposant()) {
$typeComposant = [
'id' => $composant->getTypeComposant()->getId(),
'name' => $composant->getTypeComposant()->getName(),
];
}
return $this->jsonResponse([
'id' => $composant->getId(),
'name' => $composant->getName(),
'reference' => $composant->getReference(),
'description' => $composant->getDescription(),
'prix' => $composant->getPrix(),
'typeComposant' => $typeComposant,
'constructeurs' => $constructeurs,
'createdAt' => $composant->getCreatedAt()->format('Y-m-d H:i:s'),
'updatedAt' => $composant->getUpdatedAt()->format('Y-m-d H:i:s'),
]);
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Composant;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\ComposantRepository;
use Mcp\Capability\Attribute\McpTool;
#[McpTool(
name: 'list_composants',
description: 'List composants with pagination. Filterable by name or reference.',
)]
class ListComposantsTool
{
use McpToolHelper;
public function __construct(
private readonly ComposantRepository $composants,
) {}
public function __invoke(int $page = 1, int $limit = 30, string $search = ''): array
{
$p = $this->paginationParams($page, $limit);
$countQb = $this->composants->createQueryBuilder('c')
->select('COUNT(c.id)')
;
$qb = $this->composants->createQueryBuilder('c')
->select('c.id', 'c.name', 'c.reference', 'c.prix')
->orderBy('c.name', 'ASC')
;
if ('' !== $search) {
$countQb->andWhere('LOWER(c.name) LIKE LOWER(:search) OR LOWER(c.reference) LIKE LOWER(:search)')
->setParameter('search', "%{$search}%")
;
$qb->andWhere('LOWER(c.name) LIKE LOWER(:search) OR LOWER(c.reference) LIKE LOWER(:search)')
->setParameter('search', "%{$search}%")
;
}
$total = (int) $countQb->getQuery()->getSingleScalarResult();
$items = $qb->setFirstResult($p['offset'])
->setMaxResults($p['limit'])
->getQuery()
->getArrayResult()
;
return $this->paginatedResponse($items, $total, $p['page'], $p['limit']);
}
}

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Composant;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\ComposantRepository;
use App\Repository\ConstructeurRepository;
use App\Repository\ModelTypeRepository;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
#[McpTool(
name: 'update_composant',
description: 'Update an existing composant. Only provided fields are changed. prix must be a string. Requires ROLE_GESTIONNAIRE.',
)]
class UpdateComposantTool
{
use McpToolHelper;
public function __construct(
private readonly ComposantRepository $composants,
private readonly EntityManagerInterface $em,
private readonly Security $security,
private readonly ModelTypeRepository $modelTypes,
private readonly ConstructeurRepository $constructeurs,
) {}
/**
* @param null|string[] $constructeurIds
*/
public function __invoke(
string $composantId,
?string $name = null,
?string $reference = null,
?string $description = null,
?string $prix = null,
?string $modelTypeId = null,
?array $constructeurIds = null,
): array {
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
$composant = $this->composants->find($composantId);
if (!$composant) {
$this->mcpError('not_found', "Composant not found: {$composantId}");
}
if (null !== $name) {
$composant->setName($name);
}
if (null !== $reference) {
$composant->setReference($reference);
}
if (null !== $description) {
$composant->setDescription($description);
}
if (null !== $prix) {
$composant->setPrix($prix);
}
if (null !== $modelTypeId) {
if ('' === $modelTypeId) {
$composant->setTypeComposant(null);
} else {
$modelType = $this->modelTypes->find($modelTypeId);
if (!$modelType) {
$this->mcpError('not_found', "ModelType not found: {$modelTypeId}");
}
$composant->setTypeComposant($modelType);
}
}
if (null !== $constructeurIds) {
foreach ($composant->getConstructeurs()->toArray() as $existing) {
$composant->removeConstructeur($existing);
}
foreach ($constructeurIds as $cId) {
$c = $this->constructeurs->find($cId);
if (!$c) {
$this->mcpError('not_found', "Constructeur not found: {$cId}");
}
$composant->addConstructeur($c);
}
}
$this->em->flush();
return $this->jsonResponse(['id' => $composant->getId(), 'name' => $composant->getName()]);
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Constructeur;
use App\Entity\Constructeur;
use App\Mcp\Tool\McpToolHelper;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
#[McpTool(
name: 'create_constructeur',
description: 'Create a new constructeur (manufacturer/supplier). Requires ROLE_GESTIONNAIRE.',
)]
class CreateConstructeurTool
{
use McpToolHelper;
public function __construct(
private readonly EntityManagerInterface $em,
private readonly Security $security,
) {}
public function __invoke(
string $name,
string $email = '',
string $phone = '',
): array {
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
$constructeur = new Constructeur();
$constructeur->setName($name);
$constructeur->setEmail('' !== $email ? $email : null);
$constructeur->setPhone('' !== $phone ? $phone : null);
$this->em->persist($constructeur);
$this->em->flush();
return $this->jsonResponse([
'id' => $constructeur->getId(),
'name' => $constructeur->getName(),
]);
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Constructeur;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\ConstructeurRepository;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
#[McpTool(
name: 'delete_constructeur',
description: 'Delete a constructeur by ID. Requires ROLE_GESTIONNAIRE.',
)]
class DeleteConstructeurTool
{
use McpToolHelper;
public function __construct(
private readonly ConstructeurRepository $constructeurs,
private readonly EntityManagerInterface $em,
private readonly Security $security,
) {}
public function __invoke(string $constructeurId): array
{
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
$constructeur = $this->constructeurs->find($constructeurId);
if (!$constructeur) {
$this->mcpError('not_found', "Constructeur not found: {$constructeurId}");
}
$this->em->remove($constructeur);
$this->em->flush();
return $this->jsonResponse(['deleted' => true, 'id' => $constructeurId]);
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Constructeur;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\ConstructeurRepository;
use Mcp\Capability\Attribute\McpTool;
#[McpTool(
name: 'get_constructeur',
description: 'Get a single constructeur (manufacturer/supplier) by ID with all its details.',
)]
class GetConstructeurTool
{
use McpToolHelper;
public function __construct(
private readonly ConstructeurRepository $constructeurs,
) {}
public function __invoke(string $constructeurId): array
{
$constructeur = $this->constructeurs->find($constructeurId);
if (!$constructeur) {
$this->mcpError('not_found', "Constructeur not found: {$constructeurId}");
}
return $this->jsonResponse([
'id' => $constructeur->getId(),
'name' => $constructeur->getName(),
'email' => $constructeur->getEmail(),
'phone' => $constructeur->getPhone(),
'createdAt' => $constructeur->getCreatedAt()->format('Y-m-d H:i:s'),
'updatedAt' => $constructeur->getUpdatedAt()->format('Y-m-d H:i:s'),
]);
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Constructeur;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\ConstructeurRepository;
use Mcp\Capability\Attribute\McpTool;
#[McpTool(
name: 'list_constructeurs',
description: 'List all constructeurs (manufacturers/suppliers) with pagination. Filterable by name.',
)]
class ListConstructeursTool
{
use McpToolHelper;
public function __construct(
private readonly ConstructeurRepository $constructeurs,
) {}
public function __invoke(int $page = 1, int $limit = 30, string $search = ''): array
{
$p = $this->paginationParams($page, $limit);
$countQb = $this->constructeurs->createQueryBuilder('c')
->select('COUNT(c.id)')
;
$qb = $this->constructeurs->createQueryBuilder('c')
->select('c.id', 'c.name', 'c.email', 'c.phone')
->orderBy('c.name', 'ASC')
;
if ('' !== $search) {
$countQb->andWhere('LOWER(c.name) LIKE LOWER(:search)')
->setParameter('search', "%{$search}%")
;
$qb->andWhere('LOWER(c.name) LIKE LOWER(:search)')
->setParameter('search', "%{$search}%")
;
}
$total = (int) $countQb->getQuery()->getSingleScalarResult();
$items = $qb->setFirstResult($p['offset'])
->setMaxResults($p['limit'])
->getQuery()
->getArrayResult()
;
return $this->paginatedResponse($items, $total, $p['page'], $p['limit']);
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Constructeur;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\ConstructeurRepository;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
#[McpTool(
name: 'update_constructeur',
description: 'Update an existing constructeur. Only provided fields are changed. Requires ROLE_GESTIONNAIRE.',
)]
class UpdateConstructeurTool
{
use McpToolHelper;
public function __construct(
private readonly ConstructeurRepository $constructeurs,
private readonly EntityManagerInterface $em,
private readonly Security $security,
) {}
public function __invoke(
string $constructeurId,
?string $name = null,
?string $email = null,
?string $phone = null,
): array {
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
$constructeur = $this->constructeurs->find($constructeurId);
if (!$constructeur) {
$this->mcpError('not_found', "Constructeur not found: {$constructeurId}");
}
if (null !== $name) {
$constructeur->setName($name);
}
if (null !== $email) {
$constructeur->setEmail($email);
}
if (null !== $phone) {
$constructeur->setPhone($phone);
}
$this->em->flush();
return $this->jsonResponse(['id' => $constructeur->getId(), 'name' => $constructeur->getName()]);
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\CustomField;
use App\Entity\CustomFieldValue;
use App\Mcp\Tool\McpToolHelper;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
#[McpTool(
name: 'delete_custom_field_value',
description: 'Delete a custom field value by ID. Requires ROLE_GESTIONNAIRE.',
)]
class DeleteCustomFieldValueTool
{
use McpToolHelper;
public function __construct(
private readonly EntityManagerInterface $em,
private readonly Security $security,
) {}
public function __invoke(string $customFieldValueId): array
{
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
$cfv = $this->em->getRepository(CustomFieldValue::class)->find($customFieldValueId);
if (null === $cfv) {
$this->mcpError('not_found', "CustomFieldValue not found: {$customFieldValueId}");
}
$this->em->remove($cfv);
$this->em->flush();
return $this->jsonResponse(['deleted' => true, 'id' => $customFieldValueId]);
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\CustomField;
use App\Entity\CustomFieldValue;
use App\Mcp\Tool\McpToolHelper;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
#[McpTool(
name: 'list_custom_field_values',
description: 'List all custom field values for a given entity (machine, composant, piece or product). Returns each value with its custom field name and type.',
)]
class ListCustomFieldValuesTool
{
use McpToolHelper;
private const ALLOWED_TYPES = ['machine', 'composant', 'piece', 'product'];
public function __construct(
private readonly EntityManagerInterface $em,
) {}
public function __invoke(string $entityType, string $entityId): array
{
$entityType = strtolower($entityType);
if (!in_array($entityType, self::ALLOWED_TYPES, true)) {
$this->mcpError('validation', "entityType must be one of: machine, composant, piece, product. Got '{$entityType}'.");
}
$rows = $this->em->createQueryBuilder()
->select(
'cfv.id',
'cfv.value',
'cf.id AS customFieldId',
'cf.name AS customFieldName',
'cf.type AS customFieldType',
'cf.required AS customFieldRequired',
'cfv.createdAt',
'cfv.updatedAt',
)
->from(CustomFieldValue::class, 'cfv')
->join('cfv.customField', 'cf')
->where("IDENTITY(cfv.{$entityType}) = :entityId")
->setParameter('entityId', $entityId)
->orderBy('cf.name', 'ASC')
->getQuery()
->getArrayResult()
;
return $this->jsonResponse([
'entityType' => $entityType,
'entityId' => $entityId,
'values' => $rows,
'total' => count($rows),
]);
}
}

View File

@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\CustomField;
use App\Entity\Composant;
use App\Entity\CustomField;
use App\Entity\CustomFieldValue;
use App\Entity\Machine;
use App\Entity\Piece;
use App\Entity\Product;
use App\Mcp\Tool\McpToolHelper;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
#[McpTool(
name: 'upsert_custom_field_values',
description: 'Create or update custom field values for a given entity. Each entry in the fields array needs customFieldId and value. If a value already exists for that custom field + entity, it is updated; otherwise a new one is created. Requires ROLE_GESTIONNAIRE.',
)]
class UpsertCustomFieldValuesTool
{
use McpToolHelper;
private const ALLOWED_TYPES = ['machine', 'composant', 'piece', 'product'];
public function __construct(
private readonly EntityManagerInterface $em,
private readonly Security $security,
) {}
public function __invoke(string $entityType, string $entityId, array $fields): array
{
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
$entityType = strtolower($entityType);
if (!in_array($entityType, self::ALLOWED_TYPES, true)) {
$this->mcpError('validation', "entityType must be one of: machine, composant, piece, product. Got '{$entityType}'.");
}
$entityClass = match ($entityType) {
'machine' => Machine::class,
'composant' => Composant::class,
'piece' => Piece::class,
'product' => Product::class,
};
$entity = $this->em->getRepository($entityClass)->find($entityId);
if (null === $entity) {
$this->mcpError('not_found', ucfirst($entityType)." not found: {$entityId}");
}
$results = [];
foreach ($fields as $fieldEntry) {
$customFieldId = $fieldEntry['customFieldId'] ?? null;
$value = $fieldEntry['value'] ?? '';
if (null === $customFieldId) {
$this->mcpError('validation', 'Each field entry must have a customFieldId.');
}
$customField = $this->em->getRepository(CustomField::class)->find($customFieldId);
if (null === $customField) {
$this->mcpError('not_found', "CustomField not found: {$customFieldId}");
}
$existing = $this->em->getRepository(CustomFieldValue::class)->findOneBy([
'customField' => $customField,
$entityType => $entity,
]);
if (null !== $existing) {
$existing->setValue((string) $value);
$results[] = [
'id' => $existing->getId(),
'customFieldId' => $customField->getId(),
'value' => (string) $value,
'action' => 'updated',
];
} else {
$cfv = new CustomFieldValue();
$cfv->setCustomField($customField);
$cfv->setValue((string) $value);
$setter = 'set'.ucfirst($entityType);
$cfv->{$setter}($entity);
$this->em->persist($cfv);
$this->em->flush();
$results[] = [
'id' => $cfv->getId(),
'customFieldId' => $customField->getId(),
'value' => (string) $value,
'action' => 'created',
];
}
}
$this->em->flush();
return $this->jsonResponse([
'entityType' => $entityType,
'entityId' => $entityId,
'results' => $results,
'total' => count($results),
]);
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool;
use App\Repository\ComposantRepository;
use App\Repository\MachineRepository;
use App\Repository\PieceRepository;
use App\Repository\ProductRepository;
use App\Repository\SiteRepository;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Mcp\Schema\Content\TextContent;
#[McpTool(
name: 'get_dashboard_stats',
description: 'Get global inventory statistics: count of machines, pieces, composants, products, sites, and unresolved comments. Takes no parameters.',
)]
class DashboardStatsTool
{
public function __construct(
private readonly MachineRepository $machines,
private readonly PieceRepository $pieces,
private readonly ComposantRepository $composants,
private readonly ProductRepository $products,
private readonly SiteRepository $sites,
private readonly EntityManagerInterface $em,
) {}
public function __invoke(): array
{
$unresolvedComments = (int) $this->em->createQuery(
"SELECT COUNT(c.id) FROM App\\Entity\\Comment c WHERE c.status = 'open'"
)->getSingleScalarResult();
return [
new TextContent(
text: json_encode([
'machines' => $this->machines->count([]),
'pieces' => $this->pieces->count([]),
'composants' => $this->composants->count([]),
'products' => $this->products->count([]),
'sites' => $this->sites->count([]),
'unresolvedComments' => $unresolvedComments,
], JSON_THROW_ON_ERROR)
),
];
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Document;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\DocumentRepository;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
#[McpTool(
name: 'delete_document',
description: 'Delete a document by ID. Requires ROLE_GESTIONNAIRE.',
)]
class DeleteDocumentTool
{
use McpToolHelper;
public function __construct(
private readonly DocumentRepository $documents,
private readonly EntityManagerInterface $em,
private readonly Security $security,
) {}
public function __invoke(string $documentId): array
{
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
$document = $this->documents->find($documentId);
if (!$document) {
$this->mcpError('not_found', "Document not found: {$documentId}");
}
$this->em->remove($document);
$this->em->flush();
return $this->jsonResponse(['deleted' => true, 'id' => $documentId]);
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Document;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\DocumentRepository;
use Mcp\Capability\Attribute\McpTool;
#[McpTool(
name: 'list_documents',
description: 'List documents attached to a given entity. entityType must be one of: site, machine, composant, piece, product.',
)]
class ListDocumentsTool
{
use McpToolHelper;
private const ENTITY_FIELDS = [
'site' => 'site',
'machine' => 'machine',
'composant' => 'composant',
'piece' => 'piece',
'product' => 'product',
];
public function __construct(
private readonly DocumentRepository $documents,
) {}
public function __invoke(string $entityType, string $entityId): array
{
if (!isset(self::ENTITY_FIELDS[$entityType])) {
$this->mcpError('validation', "Invalid entityType '{$entityType}'. Must be one of: site, machine, composant, piece, product.");
}
$field = self::ENTITY_FIELDS[$entityType];
$docs = $this->documents->findBy([$field => $entityId], ['createdAt' => 'DESC']);
$items = [];
foreach ($docs as $doc) {
$items[] = [
'id' => $doc->getId(),
'name' => $doc->getName(),
'filename' => $doc->getFilename(),
'fileUrl' => '/api/documents/'.$doc->getId().'/file',
'downloadUrl' => '/api/documents/'.$doc->getId().'/download',
'mimeType' => $doc->getMimeType(),
'size' => $doc->getSize(),
'createdAt' => $doc->getCreatedAt()->format('Y-m-d H:i:s'),
];
}
return $this->jsonResponse([
'entityType' => $entityType,
'entityId' => $entityId,
'items' => $items,
'total' => count($items),
]);
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool;
use App\Repository\AuditLogRepository;
use DateTimeInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
#[McpTool(
name: 'get_entity_history',
description: 'Get the audit history for a specific entity (machine, piece, composant, product). Returns list of changes with diffs.',
)]
class EntityHistoryTool
{
use McpToolHelper;
private const VALID_TYPES = ['machine', 'piece', 'composant', 'product'];
public function __construct(
private readonly AuditLogRepository $auditLogs,
private readonly Security $security,
) {}
public function __invoke(string $entityType, string $entityId): array
{
$this->requireRole($this->security, 'ROLE_VIEWER');
if (!in_array($entityType, self::VALID_TYPES, true)) {
$this->mcpError('Validation', sprintf(
'Invalid entityType "%s". Must be one of: %s',
$entityType,
implode(', ', self::VALID_TYPES),
));
}
$logs = $this->auditLogs->findEntityHistory($entityType, $entityId, 200);
$items = array_map(
static fn ($log) => [
'id' => $log->getId(),
'action' => $log->getAction(),
'diff' => $log->getDiff(),
'actorProfileId' => $log->getActorProfileId(),
'createdAt' => $log->getCreatedAt()->format(DateTimeInterface::ATOM),
],
$logs,
);
return $this->jsonResponse([
'items' => array_values($items),
'total' => count($items),
]);
}
}

View File

@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Machine;
use App\Entity\MachineComponentLink;
use App\Entity\MachinePieceLink;
use App\Entity\MachineProductLink;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\ComposantRepository;
use App\Repository\MachineComponentLinkRepository;
use App\Repository\MachinePieceLinkRepository;
use App\Repository\MachineRepository;
use App\Repository\PieceRepository;
use App\Repository\ProductRepository;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
#[McpTool(
name: 'add_machine_links',
description: 'Add one or more links (composant, piece, product) to a machine. Each link specifies a type, entityId, and optional parentLinkId / overrides. Requires ROLE_GESTIONNAIRE.',
)]
class AddMachineLinksTool
{
use McpToolHelper;
public function __construct(
private readonly EntityManagerInterface $em,
private readonly Security $security,
private readonly MachineRepository $machines,
private readonly ComposantRepository $composants,
private readonly PieceRepository $pieces,
private readonly ProductRepository $products,
private readonly MachineComponentLinkRepository $componentLinks,
private readonly MachinePieceLinkRepository $pieceLinks,
) {}
public function __invoke(string $machineId, array $links): array
{
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
$machine = $this->machines->find($machineId);
if (null === $machine) {
$this->mcpError('NotFound', "Machine {$machineId} not found.");
}
$created = [];
foreach ($links as $linkData) {
$type = $linkData['type'] ?? '';
$entityId = $linkData['entityId'] ?? '';
switch ($type) {
case 'composant':
$composant = $this->composants->find($entityId);
if (null === $composant) {
$this->mcpError('NotFound', "Composant {$entityId} not found.");
}
$link = new MachineComponentLink();
$link->setMachine($machine);
$link->setComposant($composant);
if (!empty($linkData['parentLinkId'])) {
$parent = $this->componentLinks->find($linkData['parentLinkId']);
if (null !== $parent) {
$link->setParentLink($parent);
}
}
if (isset($linkData['nameOverride'])) {
$link->setNameOverride($linkData['nameOverride']);
}
if (isset($linkData['referenceOverride'])) {
$link->setReferenceOverride($linkData['referenceOverride']);
}
if (isset($linkData['prixOverride'])) {
$link->setPrixOverride($linkData['prixOverride']);
}
$this->em->persist($link);
$created[] = ['id' => $link->getId(), 'type' => 'composant', 'entityId' => $entityId];
break;
case 'piece':
$piece = $this->pieces->find($entityId);
if (null === $piece) {
$this->mcpError('NotFound', "Piece {$entityId} not found.");
}
$link = new MachinePieceLink();
$link->setMachine($machine);
$link->setPiece($piece);
$link->setQuantity((int) ($linkData['quantity'] ?? 1));
if (!empty($linkData['parentLinkId'])) {
$parent = $this->componentLinks->find($linkData['parentLinkId']);
if (null !== $parent) {
$link->setParentLink($parent);
}
}
if (isset($linkData['nameOverride'])) {
$link->setNameOverride($linkData['nameOverride']);
}
if (isset($linkData['referenceOverride'])) {
$link->setReferenceOverride($linkData['referenceOverride']);
}
if (isset($linkData['prixOverride'])) {
$link->setPrixOverride($linkData['prixOverride']);
}
$this->em->persist($link);
$created[] = ['id' => $link->getId(), 'type' => 'piece', 'entityId' => $entityId];
break;
case 'product':
$product = $this->products->find($entityId);
if (null === $product) {
$this->mcpError('NotFound', "Product {$entityId} not found.");
}
$link = new MachineProductLink();
$link->setMachine($machine);
$link->setProduct($product);
if (!empty($linkData['parentLinkId'])) {
$parentProduct = $this->em->getRepository(MachineProductLink::class)->find($linkData['parentLinkId']);
if (null !== $parentProduct) {
$link->setParentLink($parentProduct);
}
}
if (!empty($linkData['parentComponentLinkId'])) {
$parentComp = $this->componentLinks->find($linkData['parentComponentLinkId']);
if (null !== $parentComp) {
$link->setParentComponentLink($parentComp);
}
}
if (!empty($linkData['parentPieceLinkId'])) {
$parentPiece = $this->pieceLinks->find($linkData['parentPieceLinkId']);
if (null !== $parentPiece) {
$link->setParentPieceLink($parentPiece);
}
}
$this->em->persist($link);
$created[] = ['id' => $link->getId(), 'type' => 'product', 'entityId' => $entityId];
break;
default:
$this->mcpError('Validation', "Unknown link type '{$type}'. Expected composant, piece, or product.");
}
}
$this->em->flush();
return $this->jsonResponse(['created' => $created]);
}
}

View File

@@ -0,0 +1,223 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Machine;
use App\Entity\CustomField;
use App\Entity\CustomFieldValue;
use App\Entity\Machine;
use App\Entity\MachineComponentLink;
use App\Entity\MachinePieceLink;
use App\Entity\MachineProductLink;
use App\Entity\Site;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\MachineComponentLinkRepository;
use App\Repository\MachinePieceLinkRepository;
use App\Repository\MachineProductLinkRepository;
use App\Repository\MachineRepository;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
#[McpTool(
name: 'clone_machine',
description: 'Clone an existing machine with all its links (components, pieces, products), custom fields, and constructeurs. Requires ROLE_GESTIONNAIRE.',
)]
class CloneMachineTool
{
use McpToolHelper;
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly MachineRepository $machineRepository,
private readonly MachineComponentLinkRepository $machineComponentLinkRepository,
private readonly MachinePieceLinkRepository $machinePieceLinkRepository,
private readonly MachineProductLinkRepository $machineProductLinkRepository,
private readonly Security $security,
) {}
public function __invoke(
string $machineId,
string $name,
string $siteId,
string $reference = '',
): array {
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
$source = $this->machineRepository->find($machineId);
if (!$source instanceof Machine) {
$this->mcpError('not_found', "Machine not found: {$machineId}");
}
$site = $this->entityManager->getRepository(Site::class)->find($siteId);
if (!$site) {
$this->mcpError('not_found', "Site not found: {$siteId}");
}
// Create new machine
$newMachine = new Machine();
$newMachine->setName($name);
$newMachine->setSite($site);
if ('' !== $reference) {
$newMachine->setReference($reference);
}
$newMachine->setPrix($source->getPrix());
// Copy constructeurs
foreach ($source->getConstructeurs() as $constructeur) {
$newMachine->getConstructeurs()->add($constructeur);
}
$this->entityManager->persist($newMachine);
// Copy custom fields and values
$this->cloneCustomFields($source, $newMachine);
// Copy component links (preserving hierarchy with two-pass)
$componentLinkMap = $this->cloneComponentLinks($source, $newMachine);
// Copy piece links
$pieceLinkMap = $this->clonePieceLinks($source, $newMachine, $componentLinkMap);
// Copy product links
$this->cloneProductLinks($source, $newMachine, $componentLinkMap, $pieceLinkMap);
$this->entityManager->flush();
return $this->jsonResponse([
'id' => $newMachine->getId(),
'name' => $newMachine->getName(),
'reference' => $newMachine->getReference(),
'siteId' => $site->getId(),
'clonedFrom' => $source->getId(),
]);
}
private function cloneCustomFields(Machine $source, Machine $target): void
{
foreach ($source->getCustomFields() as $cf) {
$newCf = new CustomField();
$newCf->setName($cf->getName());
$newCf->setType($cf->getType());
$newCf->setRequired($cf->isRequired());
$newCf->setDefaultValue($cf->getDefaultValue());
$newCf->setOptions($cf->getOptions());
$newCf->setOrderIndex($cf->getOrderIndex());
$newCf->setMachine($target);
$this->entityManager->persist($newCf);
}
foreach ($source->getCustomFieldValues() as $cfv) {
$newValue = new CustomFieldValue();
$newValue->setMachine($target);
$newValue->setCustomField($cfv->getCustomField());
$newValue->setValue($cfv->getValue());
$this->entityManager->persist($newValue);
}
}
/**
* @return array<string, MachineComponentLink> Map of old link ID to new link
*/
private function cloneComponentLinks(Machine $source, Machine $target): array
{
$sourceLinks = $this->machineComponentLinkRepository->findBy(['machine' => $source]);
$linkMap = [];
// First pass: create all links without parent relationships
foreach ($sourceLinks as $link) {
$newLink = new MachineComponentLink();
$newLink->setMachine($target);
$newLink->setComposant($link->getComposant());
$newLink->setNameOverride($link->getNameOverride());
$newLink->setReferenceOverride($link->getReferenceOverride());
$newLink->setPrixOverride($link->getPrixOverride());
$this->entityManager->persist($newLink);
$linkMap[$link->getId()] = $newLink;
}
// Second pass: set parent relationships
foreach ($sourceLinks as $link) {
$parent = $link->getParentLink();
if ($parent && isset($linkMap[$parent->getId()])) {
$linkMap[$link->getId()]->setParentLink($linkMap[$parent->getId()]);
}
}
return $linkMap;
}
/**
* @param array<string, MachineComponentLink> $componentLinkMap
*
* @return array<string, MachinePieceLink> Map of old link ID to new link
*/
private function clonePieceLinks(Machine $source, Machine $target, array $componentLinkMap): array
{
$sourceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $source]);
$linkMap = [];
foreach ($sourceLinks as $link) {
$newLink = new MachinePieceLink();
$newLink->setMachine($target);
$newLink->setPiece($link->getPiece());
$newLink->setNameOverride($link->getNameOverride());
$newLink->setReferenceOverride($link->getReferenceOverride());
$newLink->setPrixOverride($link->getPrixOverride());
$newLink->setQuantity($link->getQuantity());
$parent = $link->getParentLink();
if ($parent && isset($componentLinkMap[$parent->getId()])) {
$newLink->setParentLink($componentLinkMap[$parent->getId()]);
}
$this->entityManager->persist($newLink);
$linkMap[$link->getId()] = $newLink;
}
return $linkMap;
}
/**
* @param array<string, MachineComponentLink> $componentLinkMap
* @param array<string, MachinePieceLink> $pieceLinkMap
*/
private function cloneProductLinks(
Machine $source,
Machine $target,
array $componentLinkMap,
array $pieceLinkMap,
): void {
$sourceLinks = $this->machineProductLinkRepository->findBy(['machine' => $source]);
$linkMap = [];
// First pass: create all links
foreach ($sourceLinks as $link) {
$newLink = new MachineProductLink();
$newLink->setMachine($target);
$newLink->setProduct($link->getProduct());
$parentComponent = $link->getParentComponentLink();
if ($parentComponent && isset($componentLinkMap[$parentComponent->getId()])) {
$newLink->setParentComponentLink($componentLinkMap[$parentComponent->getId()]);
}
$parentPiece = $link->getParentPieceLink();
if ($parentPiece && isset($pieceLinkMap[$parentPiece->getId()])) {
$newLink->setParentPieceLink($pieceLinkMap[$parentPiece->getId()]);
}
$this->entityManager->persist($newLink);
$linkMap[$link->getId()] = $newLink;
}
// Second pass: set parent product link relationships
foreach ($sourceLinks as $link) {
$parent = $link->getParentLink();
if ($parent && isset($linkMap[$parent->getId()])) {
$linkMap[$link->getId()]->setParentLink($linkMap[$parent->getId()]);
}
}
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Machine;
use App\Entity\Machine;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\ConstructeurRepository;
use App\Repository\SiteRepository;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
#[McpTool(
name: 'create_machine',
description: 'Create a new machine. siteId is required. prix must be a string (e.g. "12.50"). Requires ROLE_GESTIONNAIRE.',
)]
class CreateMachineTool
{
use McpToolHelper;
public function __construct(
private readonly EntityManagerInterface $em,
private readonly Security $security,
private readonly SiteRepository $sites,
private readonly ConstructeurRepository $constructeurs,
) {}
/**
* @param string[] $constructeurIds
*/
public function __invoke(
string $name,
string $siteId,
string $reference = '',
string $prix = '',
array $constructeurIds = [],
): array {
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
$site = $this->sites->find($siteId);
if (!$site) {
$this->mcpError('not_found', "Site not found: {$siteId}");
}
$machine = new Machine();
$machine->setName($name);
$machine->setSite($site);
if ('' !== $reference) {
$machine->setReference($reference);
}
if ('' !== $prix) {
$machine->setPrix($prix);
}
foreach ($constructeurIds as $cId) {
$c = $this->constructeurs->find($cId);
if (!$c) {
$this->mcpError('not_found', "Constructeur not found: {$cId}");
}
$machine->addConstructeur($c);
}
$this->em->persist($machine);
$this->em->flush();
return $this->jsonResponse([
'id' => $machine->getId(),
'name' => $machine->getName(),
]);
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Machine;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\MachineRepository;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
#[McpTool(
name: 'delete_machine',
description: 'Delete a machine by ID. Requires ROLE_GESTIONNAIRE.',
)]
class DeleteMachineTool
{
use McpToolHelper;
public function __construct(
private readonly MachineRepository $machines,
private readonly EntityManagerInterface $em,
private readonly Security $security,
) {}
public function __invoke(string $machineId): array
{
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
$machine = $this->machines->find($machineId);
if (!$machine) {
$this->mcpError('not_found', "Machine not found: {$machineId}");
}
$this->em->remove($machine);
$this->em->flush();
return $this->jsonResponse(['deleted' => true, 'id' => $machineId]);
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Machine;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\MachineRepository;
use Mcp\Capability\Attribute\McpTool;
#[McpTool(
name: 'get_machine',
description: 'Get a single machine by ID with all its details, including site and constructeurs.',
)]
class GetMachineTool
{
use McpToolHelper;
public function __construct(
private readonly MachineRepository $machines,
) {}
public function __invoke(string $machineId): array
{
$machine = $this->machines->find($machineId);
if (!$machine) {
$this->mcpError('not_found', "Machine not found: {$machineId}");
}
$constructeurs = [];
foreach ($machine->getConstructeurs() as $c) {
$constructeurs[] = [
'id' => $c->getId(),
'name' => $c->getName(),
];
}
$site = null;
if ($machine->getSite()) {
$site = [
'id' => $machine->getSite()->getId(),
'name' => $machine->getSite()->getName(),
];
}
return $this->jsonResponse([
'id' => $machine->getId(),
'name' => $machine->getName(),
'reference' => $machine->getReference(),
'prix' => $machine->getPrix(),
'site' => $site,
'constructeurs' => $constructeurs,
'createdAt' => $machine->getCreatedAt()->format('Y-m-d H:i:s'),
'updatedAt' => $machine->getUpdatedAt()->format('Y-m-d H:i:s'),
]);
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Machine;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\MachineComponentLinkRepository;
use App\Repository\MachinePieceLinkRepository;
use App\Repository\MachineProductLinkRepository;
use App\Repository\MachineRepository;
use Mcp\Capability\Attribute\McpTool;
#[McpTool(
name: 'list_machine_links',
description: 'List all links (component, piece, product) for a given machine, grouped by type.',
)]
class ListMachineLinksTool
{
use McpToolHelper;
public function __construct(
private readonly MachineRepository $machines,
private readonly MachineComponentLinkRepository $componentLinks,
private readonly MachinePieceLinkRepository $pieceLinks,
private readonly MachineProductLinkRepository $productLinks,
) {}
public function __invoke(string $machineId): array
{
$machine = $this->machines->find($machineId);
if (null === $machine) {
$this->mcpError('NotFound', "Machine {$machineId} not found.");
}
$compRows = $this->componentLinks->createQueryBuilder('cl')
->select('cl.id', 'IDENTITY(cl.composant) AS entityId', 'IDENTITY(cl.parentLink) AS parentLinkId', 'cl.nameOverride', 'cl.referenceOverride', 'cl.prixOverride')
->where('cl.machine = :machine')
->setParameter('machine', $machine)
->orderBy('cl.id', 'ASC')
->getQuery()
->getArrayResult()
;
$pieceRows = $this->pieceLinks->createQueryBuilder('pl')
->select('pl.id', 'IDENTITY(pl.piece) AS entityId', 'IDENTITY(pl.parentLink) AS parentLinkId', 'pl.nameOverride', 'pl.referenceOverride', 'pl.prixOverride', 'pl.quantity')
->where('pl.machine = :machine')
->setParameter('machine', $machine)
->orderBy('pl.id', 'ASC')
->getQuery()
->getArrayResult()
;
$productRows = $this->productLinks->createQueryBuilder('prl')
->select('prl.id', 'IDENTITY(prl.product) AS entityId', 'IDENTITY(prl.parentLink) AS parentLinkId', 'IDENTITY(prl.parentComponentLink) AS parentComponentLinkId', 'IDENTITY(prl.parentPieceLink) AS parentPieceLinkId')
->where('prl.machine = :machine')
->setParameter('machine', $machine)
->orderBy('prl.id', 'ASC')
->getQuery()
->getArrayResult()
;
return $this->jsonResponse([
'machineId' => $machineId,
'componentLinks' => $compRows,
'pieceLinks' => $pieceRows,
'productLinks' => $productRows,
]);
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Machine;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\MachineRepository;
use Mcp\Capability\Attribute\McpTool;
#[McpTool(
name: 'list_machines',
description: 'List machines with pagination. Filterable by name or reference.',
)]
class ListMachinesTool
{
use McpToolHelper;
public function __construct(
private readonly MachineRepository $machines,
) {}
public function __invoke(int $page = 1, int $limit = 30, string $search = ''): array
{
$p = $this->paginationParams($page, $limit);
$countQb = $this->machines->createQueryBuilder('m')
->select('COUNT(m.id)')
;
$qb = $this->machines->createQueryBuilder('m')
->select('m.id', 'm.name', 'm.reference', 'm.prix')
->orderBy('m.name', 'ASC')
;
if ('' !== $search) {
$countQb->andWhere('LOWER(m.name) LIKE LOWER(:search) OR LOWER(m.reference) LIKE LOWER(:search)')
->setParameter('search', "%{$search}%")
;
$qb->andWhere('LOWER(m.name) LIKE LOWER(:search) OR LOWER(m.reference) LIKE LOWER(:search)')
->setParameter('search', "%{$search}%")
;
}
$total = (int) $countQb->getQuery()->getSingleScalarResult();
$items = $qb->setFirstResult($p['offset'])
->setMaxResults($p['limit'])
->getQuery()
->getArrayResult()
;
return $this->paginatedResponse($items, $total, $p['page'], $p['limit']);
}
}

View File

@@ -0,0 +1,469 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Machine;
use App\Entity\Composant;
use App\Entity\CustomField;
use App\Entity\CustomFieldValue;
use App\Entity\Machine;
use App\Entity\MachineComponentLink;
use App\Entity\MachinePieceLink;
use App\Entity\MachineProductLink;
use App\Entity\ModelType;
use App\Entity\Piece;
use App\Entity\Product;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\MachineComponentLinkRepository;
use App\Repository\MachinePieceLinkRepository;
use App\Repository\MachineProductLinkRepository;
use App\Repository\MachineRepository;
use Doctrine\Common\Collections\Collection;
use Mcp\Capability\Attribute\McpTool;
#[McpTool(
name: 'get_machine_structure',
description: 'Get the full machine hierarchy: machine info, component links (with composant details, slots, overrides), piece links (with piece details, quantity, overrides), and product links.',
)]
class MachineStructureTool
{
use McpToolHelper;
public function __construct(
private readonly MachineRepository $machineRepository,
private readonly MachineComponentLinkRepository $machineComponentLinkRepository,
private readonly MachinePieceLinkRepository $machinePieceLinkRepository,
private readonly MachineProductLinkRepository $machineProductLinkRepository,
) {}
public function __invoke(string $machineId): array
{
$machine = $this->machineRepository->find($machineId);
if (!$machine instanceof Machine) {
$this->mcpError('not_found', "Machine not found: {$machineId}");
}
$componentLinks = $this->machineComponentLinkRepository->findBy(['machine' => $machine]);
$pieceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $machine]);
$productLinks = $this->machineProductLinkRepository->findBy(['machine' => $machine]);
return $this->jsonResponse($this->normalizeStructureResponse(
$machine,
$componentLinks,
$pieceLinks,
$productLinks,
));
}
/**
* @param MachineComponentLink[] $componentLinks
* @param MachinePieceLink[] $pieceLinks
* @param MachineProductLink[] $productLinks
*/
private function normalizeStructureResponse(
Machine $machine,
array $componentLinks,
array $pieceLinks,
array $productLinks,
): array {
$normalizedComponentLinks = $this->normalizeComponentLinks($componentLinks);
$componentIndex = $this->indexById($normalizedComponentLinks);
$normalizedPieceLinks = $this->normalizePieceLinks($pieceLinks);
$childIds = [];
foreach ($normalizedComponentLinks as $link) {
$parentId = $link['parentComponentLinkId'] ?? null;
if ($parentId && isset($componentIndex[$parentId])) {
$componentIndex[$parentId]['childLinks'][] = $link;
$childIds[$link['id']] = true;
}
}
$this->attachPiecesToComponents($componentIndex, $normalizedPieceLinks);
$rootComponents = array_filter(
$componentIndex,
static fn (array $link) => !isset($childIds[$link['id']]),
);
return [
'machine' => $this->normalizeMachine($machine),
'componentLinks' => array_values($rootComponents),
'pieceLinks' => $normalizedPieceLinks,
'productLinks' => $this->normalizeProductLinks($productLinks),
];
}
private function attachPiecesToComponents(array &$componentIndex, array $pieceLinks): void
{
foreach ($pieceLinks as $pieceLink) {
$parentId = $pieceLink['parentComponentLinkId'] ?? null;
if ($parentId && isset($componentIndex[$parentId])) {
$componentIndex[$parentId]['pieceLinks'][] = $pieceLink;
}
}
foreach ($componentIndex as &$component) {
if (!empty($component['childLinks'])) {
$this->attachPiecesToChildComponents($component['childLinks'], $pieceLinks);
}
}
}
private function attachPiecesToChildComponents(array &$childLinks, array $pieceLinks): void
{
foreach ($childLinks as &$child) {
$childId = $child['id'] ?? null;
if ($childId) {
foreach ($pieceLinks as $pieceLink) {
if (($pieceLink['parentComponentLinkId'] ?? null) === $childId) {
$child['pieceLinks'][] = $pieceLink;
}
}
}
if (!empty($child['childLinks'])) {
$this->attachPiecesToChildComponents($child['childLinks'], $pieceLinks);
}
}
}
private function normalizeMachine(Machine $machine): array
{
$site = $machine->getSite();
return [
'id' => $machine->getId(),
'name' => $machine->getName(),
'reference' => $machine->getReference(),
'prix' => $machine->getPrix(),
'siteId' => $site->getId(),
'site' => [
'id' => $site->getId(),
'name' => $site->getName(),
],
'constructeurs' => $this->normalizeConstructeurs($machine->getConstructeurs()),
'customFields' => $this->normalizeCustomFields($machine->getCustomFields()),
'customFieldValues' => $this->normalizeCustomFieldValues($machine->getCustomFieldValues()),
];
}
/**
* @param MachineComponentLink[] $links
*/
private function normalizeComponentLinks(array $links): array
{
return array_map(function (MachineComponentLink $link): array {
$composant = $link->getComposant();
$parentLink = $link->getParentLink();
return [
'id' => $link->getId(),
'linkId' => $link->getId(),
'machineId' => $link->getMachine()->getId(),
'composantId' => $composant->getId(),
'composant' => $this->normalizeComposant($composant),
'parentLinkId' => $parentLink?->getId(),
'parentComponentLinkId' => $parentLink?->getId(),
'parentComponentId' => $parentLink?->getComposant()->getId(),
'overrides' => $this->normalizeOverrides($link),
'childLinks' => [],
'pieceLinks' => [],
];
}, $links);
}
/**
* @param MachinePieceLink[] $links
*/
private function normalizePieceLinks(array $links): array
{
return array_map(function (MachinePieceLink $link): array {
$piece = $link->getPiece();
$parentLink = $link->getParentLink();
return [
'id' => $link->getId(),
'linkId' => $link->getId(),
'machineId' => $link->getMachine()->getId(),
'pieceId' => $piece->getId(),
'piece' => $this->normalizePiece($piece),
'parentLinkId' => $parentLink?->getId(),
'parentComponentLinkId' => $parentLink?->getId(),
'parentComponentId' => $parentLink?->getComposant()->getId(),
'overrides' => $this->normalizeOverrides($link),
'quantity' => $this->resolvePieceQuantity($link),
];
}, $links);
}
private function resolvePieceQuantity(MachinePieceLink $link): int
{
$parentLink = $link->getParentLink();
if (!$parentLink) {
return $link->getQuantity();
}
$composant = $parentLink->getComposant();
$piece = $link->getPiece();
foreach ($composant->getPieceSlots() as $slot) {
if ($slot->getSelectedPiece()?->getId() === $piece->getId()) {
return $slot->getQuantity();
}
}
return $link->getQuantity();
}
/**
* @param MachineProductLink[] $links
*/
private function normalizeProductLinks(array $links): array
{
return array_map(function (MachineProductLink $link): array {
$product = $link->getProduct();
return [
'id' => $link->getId(),
'linkId' => $link->getId(),
'machineId' => $link->getMachine()->getId(),
'productId' => $product->getId(),
'product' => $this->normalizeProduct($product),
'parentLinkId' => $link->getParentLink()?->getId(),
'parentComponentLinkId' => $link->getParentComponentLink()?->getId(),
'parentPieceLinkId' => $link->getParentPieceLink()?->getId(),
];
}, $links);
}
private function normalizeComposant(Composant $composant): array
{
$type = $composant->getTypeComposant();
return [
'id' => $composant->getId(),
'name' => $composant->getName(),
'reference' => $composant->getReference(),
'prix' => $composant->getPrix(),
'typeComposantId' => $type?->getId(),
'typeComposant' => $this->normalizeModelType($type),
'productId' => $composant->getProduct()?->getId(),
'product' => $composant->getProduct() ? $this->normalizeProduct($composant->getProduct()) : null,
'structure' => $this->buildStructureFromSlots($composant),
'constructeurs' => $this->normalizeConstructeurs($composant->getConstructeurs()),
'customFields' => $type ? $this->normalizeCustomFieldDefinitions($type->getComponentCustomFields()) : [],
'customFieldValues' => $this->normalizeCustomFieldValues($composant->getCustomFieldValues()),
];
}
private function buildStructureFromSlots(Composant $composant): array
{
$pieces = [];
foreach ($composant->getPieceSlots() as $slot) {
$pieceData = [
'slotId' => $slot->getId(),
'typePieceId' => $slot->getTypePiece()?->getId(),
'quantity' => $slot->getQuantity(),
'selectedPieceId' => $slot->getSelectedPiece()?->getId(),
];
if ($slot->getSelectedPiece()) {
$pieceData['resolvedPiece'] = $this->normalizePiece($slot->getSelectedPiece());
}
$pieces[] = $pieceData;
}
$subcomponents = [];
foreach ($composant->getSubcomponentSlots() as $slot) {
$subcomponents[] = [
'alias' => $slot->getAlias(),
'familyCode' => $slot->getFamilyCode(),
'typeComposantId' => $slot->getTypeComposant()?->getId(),
'selectedComponentId' => $slot->getSelectedComposant()?->getId(),
];
}
$products = [];
foreach ($composant->getProductSlots() as $slot) {
$products[] = [
'typeProductId' => $slot->getTypeProduct()?->getId(),
'familyCode' => $slot->getFamilyCode(),
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
];
}
return [
'pieces' => $pieces,
'subcomponents' => $subcomponents,
'products' => $products,
];
}
private function normalizePiece(Piece $piece): array
{
$type = $piece->getTypePiece();
return [
'id' => $piece->getId(),
'name' => $piece->getName(),
'reference' => $piece->getReference(),
'prix' => $piece->getPrix(),
'typePieceId' => $type?->getId(),
'typePiece' => $this->normalizeModelType($type),
'productId' => $piece->getProduct()?->getId(),
'product' => $piece->getProduct() ? $this->normalizeProduct($piece->getProduct()) : null,
'constructeurs' => $this->normalizeConstructeurs($piece->getConstructeurs()),
'customFields' => $type ? $this->normalizeCustomFieldDefinitions($type->getPieceCustomFields()) : [],
'customFieldValues' => $this->normalizeCustomFieldValues($piece->getCustomFieldValues()),
];
}
private function normalizeProduct(Product $product): array
{
$type = $product->getTypeProduct();
return [
'id' => $product->getId(),
'name' => $product->getName(),
'reference' => $product->getReference(),
'supplierPrice' => $product->getSupplierPrice(),
'typeProductId' => $type?->getId(),
'typeProduct' => $this->normalizeModelType($type),
'constructeurs' => $this->normalizeConstructeurs($product->getConstructeurs()),
'customFields' => $type ? $this->normalizeCustomFieldDefinitions($type->getProductCustomFields()) : [],
'customFieldValues' => $this->normalizeCustomFieldValues($product->getCustomFieldValues()),
];
}
private function normalizeModelType(?ModelType $type): ?array
{
if (!$type instanceof ModelType) {
return null;
}
return [
'id' => $type->getId(),
'name' => $type->getName(),
'code' => $type->getCode(),
'category' => $type->getCategory()->value,
'structure' => $type->getStructure(),
];
}
private function normalizeConstructeurs(Collection $constructeurs): array
{
$items = [];
foreach ($constructeurs as $constructeur) {
$items[] = [
'id' => $constructeur->getId(),
'name' => $constructeur->getName(),
'email' => $constructeur->getEmail(),
'phone' => $constructeur->getPhone(),
];
}
return $items;
}
private function normalizeCustomFields(Collection $customFields): array
{
$items = [];
foreach ($customFields as $customField) {
if (!$customField instanceof CustomField) {
continue;
}
$items[] = [
'id' => $customField->getId(),
'name' => $customField->getName(),
'type' => $customField->getType(),
'required' => $customField->isRequired(),
'options' => $customField->getOptions(),
'defaultValue' => $customField->getDefaultValue(),
'orderIndex' => $customField->getOrderIndex(),
];
}
return $items;
}
private function normalizeCustomFieldDefinitions(Collection $customFields): array
{
$items = [];
foreach ($customFields as $cf) {
if (!$cf instanceof CustomField) {
continue;
}
$items[] = [
'id' => $cf->getId(),
'name' => $cf->getName(),
'type' => $cf->getType(),
'required' => $cf->isRequired(),
'options' => $cf->getOptions(),
'defaultValue' => $cf->getDefaultValue(),
'orderIndex' => $cf->getOrderIndex(),
];
}
usort($items, static fn (array $a, array $b) => $a['orderIndex'] <=> $b['orderIndex']);
return $items;
}
private function normalizeCustomFieldValues(Collection $customFieldValues): array
{
$items = [];
foreach ($customFieldValues as $cfv) {
if (!$cfv instanceof CustomFieldValue) {
continue;
}
$cf = $cfv->getCustomField();
$items[] = [
'id' => $cfv->getId(),
'value' => $cfv->getValue(),
'customField' => [
'id' => $cf->getId(),
'name' => $cf->getName(),
'type' => $cf->getType(),
'required' => $cf->isRequired(),
'options' => $cf->getOptions(),
'defaultValue' => $cf->getDefaultValue(),
'orderIndex' => $cf->getOrderIndex(),
],
];
}
return $items;
}
private function normalizeOverrides(object $link): ?array
{
$name = method_exists($link, 'getNameOverride') ? $link->getNameOverride() : null;
$reference = method_exists($link, 'getReferenceOverride') ? $link->getReferenceOverride() : null;
$prix = method_exists($link, 'getPrixOverride') ? $link->getPrixOverride() : null;
if (null === $name && null === $reference && null === $prix) {
return null;
}
return [
'name' => $name,
'reference' => $reference,
'prix' => $prix,
];
}
private function indexById(array $links): array
{
$indexed = [];
foreach ($links as $link) {
if (is_array($link) && isset($link['id'])) {
$indexed[$link['id']] = $link;
}
}
return $indexed;
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Machine;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\MachineComponentLinkRepository;
use App\Repository\MachinePieceLinkRepository;
use App\Repository\MachineProductLinkRepository;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
#[McpTool(
name: 'remove_machine_link',
description: 'Remove a machine link by id and type (composant, piece, or product). Requires ROLE_GESTIONNAIRE.',
)]
class RemoveMachineLinkTool
{
use McpToolHelper;
public function __construct(
private readonly EntityManagerInterface $em,
private readonly Security $security,
private readonly MachineComponentLinkRepository $componentLinks,
private readonly MachinePieceLinkRepository $pieceLinks,
private readonly MachineProductLinkRepository $productLinks,
) {}
public function __invoke(string $linkId, string $linkType): array
{
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
$link = match ($linkType) {
'composant' => $this->componentLinks->find($linkId),
'piece' => $this->pieceLinks->find($linkId),
'product' => $this->productLinks->find($linkId),
default => $this->mcpError('Validation', "Unknown link type '{$linkType}'. Expected composant, piece, or product."),
};
if (null === $link) {
$this->mcpError('NotFound', "Link {$linkId} of type {$linkType} not found.");
}
$this->em->remove($link);
$this->em->flush();
return $this->jsonResponse(['deleted' => true, 'id' => $linkId, 'type' => $linkType]);
}
}

View File

@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Machine;
use App\Entity\MachineComponentLink;
use App\Entity\MachinePieceLink;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\MachineComponentLinkRepository;
use App\Repository\MachinePieceLinkRepository;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
#[McpTool(
name: 'update_machine_link',
description: 'Update overrides (nameOverride, referenceOverride, prixOverride) or quantity on an existing machine link. Requires ROLE_GESTIONNAIRE.',
)]
class UpdateMachineLinkTool
{
use McpToolHelper;
public function __construct(
private readonly EntityManagerInterface $em,
private readonly Security $security,
private readonly MachineComponentLinkRepository $componentLinks,
private readonly MachinePieceLinkRepository $pieceLinks,
) {}
public function __invoke(
string $linkId,
string $linkType,
?string $nameOverride = null,
?string $referenceOverride = null,
?string $prixOverride = null,
?int $quantity = null,
): array {
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
switch ($linkType) {
case 'composant':
$link = $this->componentLinks->find($linkId);
if (null === $link) {
$this->mcpError('NotFound', "MachineComponentLink {$linkId} not found.");
}
$this->applyOverrides($link, $nameOverride, $referenceOverride, $prixOverride);
$this->em->flush();
return $this->jsonResponse([
'id' => $link->getId(),
'type' => 'composant',
'nameOverride' => $link->getNameOverride(),
'referenceOverride' => $link->getReferenceOverride(),
'prixOverride' => $link->getPrixOverride(),
]);
case 'piece':
$link = $this->pieceLinks->find($linkId);
if (null === $link) {
$this->mcpError('NotFound', "MachinePieceLink {$linkId} not found.");
}
$this->applyOverrides($link, $nameOverride, $referenceOverride, $prixOverride);
if (null !== $quantity) {
$link->setQuantity($quantity);
}
$this->em->flush();
return $this->jsonResponse([
'id' => $link->getId(),
'type' => 'piece',
'nameOverride' => $link->getNameOverride(),
'referenceOverride' => $link->getReferenceOverride(),
'prixOverride' => $link->getPrixOverride(),
'quantity' => $link->getQuantity(),
]);
case 'product':
$this->mcpError('Validation', 'Product links do not have updatable overrides.');
// no break
default:
$this->mcpError('Validation', "Unknown link type '{$linkType}'. Expected composant, piece, or product.");
}
}
private function applyOverrides(MachineComponentLink|MachinePieceLink $link, ?string $nameOverride, ?string $referenceOverride, ?string $prixOverride): void
{
if (null !== $nameOverride) {
$link->setNameOverride($nameOverride);
}
if (null !== $referenceOverride) {
$link->setReferenceOverride($referenceOverride);
}
if (null !== $prixOverride) {
$link->setPrixOverride($prixOverride);
}
}
}

View File

@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Machine;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\ConstructeurRepository;
use App\Repository\MachineRepository;
use App\Repository\SiteRepository;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
#[McpTool(
name: 'update_machine',
description: 'Update an existing machine. Only provided fields are changed. prix must be a string. Requires ROLE_GESTIONNAIRE.',
)]
class UpdateMachineTool
{
use McpToolHelper;
public function __construct(
private readonly MachineRepository $machines,
private readonly EntityManagerInterface $em,
private readonly Security $security,
private readonly SiteRepository $sites,
private readonly ConstructeurRepository $constructeurs,
) {}
/**
* @param null|string[] $constructeurIds
*/
public function __invoke(
string $machineId,
?string $name = null,
?string $reference = null,
?string $prix = null,
?string $siteId = null,
?array $constructeurIds = null,
): array {
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
$machine = $this->machines->find($machineId);
if (!$machine) {
$this->mcpError('not_found', "Machine not found: {$machineId}");
}
if (null !== $name) {
$machine->setName($name);
}
if (null !== $reference) {
$machine->setReference($reference);
}
if (null !== $prix) {
$machine->setPrix($prix);
}
if (null !== $siteId) {
$site = $this->sites->find($siteId);
if (!$site) {
$this->mcpError('not_found', "Site not found: {$siteId}");
}
$machine->setSite($site);
}
if (null !== $constructeurIds) {
foreach ($machine->getConstructeurs()->toArray() as $existing) {
$machine->removeConstructeur($existing);
}
foreach ($constructeurIds as $cId) {
$c = $this->constructeurs->find($cId);
if (!$c) {
$this->mcpError('not_found', "Constructeur not found: {$cId}");
}
$machine->addConstructeur($c);
}
}
$this->em->flush();
return $this->jsonResponse(['id' => $machine->getId(), 'name' => $machine->getName()]);
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool;
use Mcp\Schema\Content\TextContent;
use RuntimeException;
use Symfony\Bundle\SecurityBundle\Security;
trait McpToolHelper
{
private function requireRole(Security $security, string $role): void
{
if (!$security->isGranted($role)) {
throw new RuntimeException("Permission denied: {$role} required.");
}
}
/**
* @return array{TextContent}
*/
private function jsonResponse(array $data): array
{
return [new TextContent(text: json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE))];
}
private function mcpError(string $category, string $message): never
{
throw new RuntimeException("{$category}: {$message}");
}
/**
* @return array{page: int, limit: int, offset: int}
*/
private function paginationParams(int $page = 1, int $limit = 30): array
{
$page = max(1, $page);
$limit = min(100, max(1, $limit));
$offset = ($page - 1) * $limit;
return ['page' => $page, 'limit' => $limit, 'offset' => $offset];
}
/**
* @return array{TextContent}
*/
private function paginatedResponse(array $items, int $total, int $page, int $limit): array
{
return $this->jsonResponse([
'items' => $items,
'total' => $total,
'page' => $page,
'limit' => $limit,
'pageCount' => (int) ceil($total / max(1, $limit)),
]);
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\ModelType;
use App\Entity\ModelType;
use App\Enum\ModelCategory;
use App\Mcp\Tool\McpToolHelper;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
#[McpTool(
name: 'create_model_type',
description: 'Create a new model type. Category must be one of: composant, piece, product. Requires ROLE_GESTIONNAIRE.',
)]
class CreateModelTypeTool
{
use McpToolHelper;
public function __construct(
private readonly EntityManagerInterface $em,
private readonly Security $security,
) {}
public function __invoke(string $name, string $category, string $code = ''): array
{
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
$enumCategory = match (strtolower($category)) {
'composant', 'component' => ModelCategory::COMPONENT,
'piece' => ModelCategory::PIECE,
'product' => ModelCategory::PRODUCT,
default => null,
};
if (null === $enumCategory) {
$this->mcpError('validation', "Invalid category '{$category}'. Must be one of: composant, piece, product.");
}
$mt = new ModelType();
$mt->setName($name);
$mt->setCategory($enumCategory);
$mt->setCode('' !== $code ? $code : strtoupper(substr(str_replace(' ', '-', $name), 0, 20)).'-'.bin2hex(random_bytes(3)));
$this->em->persist($mt);
$this->em->flush();
return $this->jsonResponse([
'id' => $mt->getId(),
'name' => $mt->getName(),
'code' => $mt->getCode(),
'category' => $mt->getCategory()->value,
]);
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\ModelType;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\ModelTypeRepository;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
#[McpTool(
name: 'delete_model_type',
description: 'Delete a model type by ID. Requires ROLE_GESTIONNAIRE.',
)]
class DeleteModelTypeTool
{
use McpToolHelper;
public function __construct(
private readonly ModelTypeRepository $modelTypes,
private readonly EntityManagerInterface $em,
private readonly Security $security,
) {}
public function __invoke(string $modelTypeId): array
{
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
$mt = $this->modelTypes->find($modelTypeId);
if (!$mt) {
$this->mcpError('not_found', "ModelType not found: {$modelTypeId}");
}
$this->em->remove($mt);
$this->em->flush();
return $this->jsonResponse(['deleted' => true, 'id' => $modelTypeId]);
}
}

View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\ModelType;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\ModelTypeRepository;
use Mcp\Capability\Attribute\McpTool;
#[McpTool(
name: 'get_model_type',
description: 'Get a single model type by ID with full details including skeleton requirements.',
)]
class GetModelTypeTool
{
use McpToolHelper;
public function __construct(
private readonly ModelTypeRepository $modelTypes,
) {}
public function __invoke(string $modelTypeId): array
{
$mt = $this->modelTypes->find($modelTypeId);
if (!$mt) {
$this->mcpError('not_found', "ModelType not found: {$modelTypeId}");
}
$skeletonPieces = [];
foreach ($mt->getSkeletonPieceRequirements() as $req) {
$skeletonPieces[] = [
'id' => $req->getId(),
'typePieceId' => $req->getTypePiece()->getId(),
'typePiece' => $req->getTypePiece()->getName(),
'position' => $req->getPosition(),
];
}
$skeletonProducts = [];
foreach ($mt->getSkeletonProductRequirements() as $req) {
$skeletonProducts[] = [
'id' => $req->getId(),
'typeProductId' => $req->getTypeProduct()->getId(),
'typeProduct' => $req->getTypeProduct()->getName(),
'familyCode' => $req->getFamilyCode(),
'position' => $req->getPosition(),
];
}
$skeletonSubcomponents = [];
foreach ($mt->getSkeletonSubcomponentRequirements() as $req) {
$skeletonSubcomponents[] = [
'id' => $req->getId(),
'alias' => $req->getAlias(),
'familyCode' => $req->getFamilyCode(),
'typeComposantId' => $req->getTypeComposant()?->getId(),
'typeComposant' => $req->getTypeComposant()?->getName(),
'position' => $req->getPosition(),
];
}
return $this->jsonResponse([
'id' => $mt->getId(),
'name' => $mt->getName(),
'code' => $mt->getCode(),
'category' => $mt->getCategory()->value,
'notes' => $mt->getNotes(),
'description' => $mt->getDescription(),
'skeletonPieceRequirements' => $skeletonPieces,
'skeletonProductRequirements' => $skeletonProducts,
'skeletonSubcomponentRequirements' => $skeletonSubcomponents,
'createdAt' => $mt->getCreatedAt()->format('Y-m-d H:i:s'),
'updatedAt' => $mt->getUpdatedAt()->format('Y-m-d H:i:s'),
]);
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\ModelType;
use App\Enum\ModelCategory;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\ModelTypeRepository;
use Mcp\Capability\Attribute\McpTool;
#[McpTool(
name: 'list_model_types',
description: 'List model types with pagination. Filterable by category (composant, piece, product).',
)]
class ListModelTypesTool
{
use McpToolHelper;
public function __construct(
private readonly ModelTypeRepository $modelTypes,
) {}
public function __invoke(int $page = 1, int $limit = 30, string $category = ''): array
{
$p = $this->paginationParams($page, $limit);
$countQb = $this->modelTypes->createQueryBuilder('mt')
->select('COUNT(mt.id)')
;
$qb = $this->modelTypes->createQueryBuilder('mt')
->select('mt.id', 'mt.name', 'mt.code', 'mt.category')
->orderBy('mt.name', 'ASC')
;
if ('' !== $category) {
$enumCategory = $this->resolveCategory($category);
if (null === $enumCategory) {
$this->mcpError('validation', "Invalid category '{$category}'. Must be one of: composant, piece, product.");
}
$countQb->andWhere('mt.category = :category')
->setParameter('category', $enumCategory)
;
$qb->andWhere('mt.category = :category')
->setParameter('category', $enumCategory)
;
}
$total = (int) $countQb->getQuery()->getSingleScalarResult();
$items = $qb->setFirstResult($p['offset'])
->setMaxResults($p['limit'])
->getQuery()
->getArrayResult()
;
// Convert ModelCategory enum to string in results
foreach ($items as &$item) {
if ($item['category'] instanceof ModelCategory) {
$item['category'] = $item['category']->value;
}
}
return $this->paginatedResponse($items, $total, $p['page'], $p['limit']);
}
private function resolveCategory(string $category): ?ModelCategory
{
return match (strtolower($category)) {
'composant', 'component' => ModelCategory::COMPONENT,
'piece' => ModelCategory::PIECE,
'product' => ModelCategory::PRODUCT,
default => null,
};
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\ModelType;
use App\DTO\SyncConfirmation;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\ModelTypeRepository;
use App\Service\ModelTypeSyncService;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
#[McpTool(
name: 'sync_model_type',
description: 'Preview or sync a model type structure. Action "preview" shows what would change. Action "sync" applies the pending structure. Requires ROLE_GESTIONNAIRE.',
)]
class SyncModelTypeTool
{
use McpToolHelper;
public function __construct(
private readonly ModelTypeRepository $modelTypes,
private readonly ModelTypeSyncService $syncService,
private readonly EntityManagerInterface $em,
private readonly Security $security,
) {}
public function __invoke(
string $modelTypeId,
string $action,
?array $structure = null,
bool $confirmDeletions = false,
bool $confirmTypeChanges = false,
): array {
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
if (!in_array($action, ['preview', 'sync'], true)) {
$this->mcpError('validation', "Invalid action '{$action}'. Must be 'preview' or 'sync'.");
}
$mt = $this->modelTypes->find($modelTypeId);
if (!$mt) {
$this->mcpError('not_found', "ModelType not found: {$modelTypeId}");
}
if ('preview' === $action) {
$result = $this->syncService->preview($mt, $structure ?? []);
return $this->jsonResponse($result->jsonSerialize());
}
// sync action
$confirmation = new SyncConfirmation(
confirmDeletions: $confirmDeletions,
confirmTypeChanges: $confirmTypeChanges,
);
$result = $this->em->wrapInTransaction(function () use ($mt, $confirmation) {
return $this->syncService->execute($mt, $confirmation);
});
return $this->jsonResponse($result->jsonSerialize());
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\ModelType;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\ModelTypeRepository;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
#[McpTool(
name: 'update_model_type',
description: 'Update an existing model type. Only provided fields are changed. Requires ROLE_GESTIONNAIRE.',
)]
class UpdateModelTypeTool
{
use McpToolHelper;
public function __construct(
private readonly ModelTypeRepository $modelTypes,
private readonly EntityManagerInterface $em,
private readonly Security $security,
) {}
public function __invoke(string $modelTypeId, ?string $name = null, ?string $code = null): array
{
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
$mt = $this->modelTypes->find($modelTypeId);
if (!$mt) {
$this->mcpError('not_found', "ModelType not found: {$modelTypeId}");
}
if (null !== $name) {
$mt->setName($name);
}
if (null !== $code) {
$mt->setCode($code);
}
$this->em->flush();
return $this->jsonResponse([
'id' => $mt->getId(),
'name' => $mt->getName(),
'code' => $mt->getCode(),
'category' => $mt->getCategory()->value,
]);
}
}

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Piece;
use App\Entity\Piece;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\ConstructeurRepository;
use App\Repository\ModelTypeRepository;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
#[McpTool(
name: 'create_piece',
description: 'Create a new piece. prix must be a string (e.g. "12.50"). Requires ROLE_GESTIONNAIRE.',
)]
class CreatePieceTool
{
use McpToolHelper;
public function __construct(
private readonly EntityManagerInterface $em,
private readonly Security $security,
private readonly ModelTypeRepository $modelTypes,
private readonly ConstructeurRepository $constructeurs,
) {}
/**
* @param string[] $constructeurIds
*/
public function __invoke(
string $name,
string $reference = '',
string $description = '',
string $prix = '',
string $modelTypeId = '',
array $constructeurIds = [],
): array {
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
$piece = new Piece();
$piece->setName($name);
if ('' !== $reference) {
$piece->setReference($reference);
}
if ('' !== $description) {
$piece->setDescription($description);
}
if ('' !== $prix) {
$piece->setPrix($prix);
}
if ('' !== $modelTypeId) {
$modelType = $this->modelTypes->find($modelTypeId);
if (!$modelType) {
$this->mcpError('not_found', "ModelType not found: {$modelTypeId}");
}
$piece->setTypePiece($modelType);
}
foreach ($constructeurIds as $cId) {
$c = $this->constructeurs->find($cId);
if (!$c) {
$this->mcpError('not_found', "Constructeur not found: {$cId}");
}
$piece->addConstructeur($c);
}
$this->em->persist($piece);
$this->em->flush();
return $this->jsonResponse([
'id' => $piece->getId(),
'name' => $piece->getName(),
]);
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Piece;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\PieceRepository;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
#[McpTool(
name: 'delete_piece',
description: 'Delete a piece by ID. Requires ROLE_GESTIONNAIRE.',
)]
class DeletePieceTool
{
use McpToolHelper;
public function __construct(
private readonly PieceRepository $pieces,
private readonly EntityManagerInterface $em,
private readonly Security $security,
) {}
public function __invoke(string $pieceId): array
{
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
$piece = $this->pieces->find($pieceId);
if (!$piece) {
$this->mcpError('not_found', "Piece not found: {$pieceId}");
}
$this->em->remove($piece);
$this->em->flush();
return $this->jsonResponse(['deleted' => true, 'id' => $pieceId]);
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Piece;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\PieceRepository;
use Mcp\Capability\Attribute\McpTool;
#[McpTool(
name: 'get_piece',
description: 'Get a single piece by ID with all its details, including typePiece and constructeurs.',
)]
class GetPieceTool
{
use McpToolHelper;
public function __construct(
private readonly PieceRepository $pieces,
) {}
public function __invoke(string $pieceId): array
{
$piece = $this->pieces->find($pieceId);
if (!$piece) {
$this->mcpError('not_found', "Piece not found: {$pieceId}");
}
$constructeurs = [];
foreach ($piece->getConstructeurs() as $c) {
$constructeurs[] = [
'id' => $c->getId(),
'name' => $c->getName(),
];
}
$typePiece = null;
if ($piece->getTypePiece()) {
$typePiece = [
'id' => $piece->getTypePiece()->getId(),
'name' => $piece->getTypePiece()->getName(),
];
}
return $this->jsonResponse([
'id' => $piece->getId(),
'name' => $piece->getName(),
'reference' => $piece->getReference(),
'description' => $piece->getDescription(),
'prix' => $piece->getPrix(),
'typePiece' => $typePiece,
'constructeurs' => $constructeurs,
'createdAt' => $piece->getCreatedAt()->format('Y-m-d H:i:s'),
'updatedAt' => $piece->getUpdatedAt()->format('Y-m-d H:i:s'),
]);
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Piece;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\PieceRepository;
use Mcp\Capability\Attribute\McpTool;
#[McpTool(
name: 'list_pieces',
description: 'List pieces with pagination. Filterable by name or reference.',
)]
class ListPiecesTool
{
use McpToolHelper;
public function __construct(
private readonly PieceRepository $pieces,
) {}
public function __invoke(int $page = 1, int $limit = 30, string $search = ''): array
{
$p = $this->paginationParams($page, $limit);
$countQb = $this->pieces->createQueryBuilder('pi')
->select('COUNT(pi.id)')
;
$qb = $this->pieces->createQueryBuilder('pi')
->select('pi.id', 'pi.name', 'pi.reference', 'pi.prix')
->orderBy('pi.name', 'ASC')
;
if ('' !== $search) {
$countQb->andWhere('LOWER(pi.name) LIKE LOWER(:search) OR LOWER(pi.reference) LIKE LOWER(:search)')
->setParameter('search', "%{$search}%")
;
$qb->andWhere('LOWER(pi.name) LIKE LOWER(:search) OR LOWER(pi.reference) LIKE LOWER(:search)')
->setParameter('search', "%{$search}%")
;
}
$total = (int) $countQb->getQuery()->getSingleScalarResult();
$items = $qb->setFirstResult($p['offset'])
->setMaxResults($p['limit'])
->getQuery()
->getArrayResult()
;
return $this->paginatedResponse($items, $total, $p['page'], $p['limit']);
}
}

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Piece;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\ConstructeurRepository;
use App\Repository\ModelTypeRepository;
use App\Repository\PieceRepository;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
#[McpTool(
name: 'update_piece',
description: 'Update an existing piece. Only provided fields are changed. prix must be a string. Requires ROLE_GESTIONNAIRE.',
)]
class UpdatePieceTool
{
use McpToolHelper;
public function __construct(
private readonly PieceRepository $pieces,
private readonly EntityManagerInterface $em,
private readonly Security $security,
private readonly ModelTypeRepository $modelTypes,
private readonly ConstructeurRepository $constructeurs,
) {}
/**
* @param null|string[] $constructeurIds
*/
public function __invoke(
string $pieceId,
?string $name = null,
?string $reference = null,
?string $description = null,
?string $prix = null,
?string $modelTypeId = null,
?array $constructeurIds = null,
): array {
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
$piece = $this->pieces->find($pieceId);
if (!$piece) {
$this->mcpError('not_found', "Piece not found: {$pieceId}");
}
if (null !== $name) {
$piece->setName($name);
}
if (null !== $reference) {
$piece->setReference($reference);
}
if (null !== $description) {
$piece->setDescription($description);
}
if (null !== $prix) {
$piece->setPrix($prix);
}
if (null !== $modelTypeId) {
if ('' === $modelTypeId) {
$piece->setTypePiece(null);
} else {
$modelType = $this->modelTypes->find($modelTypeId);
if (!$modelType) {
$this->mcpError('not_found', "ModelType not found: {$modelTypeId}");
}
$piece->setTypePiece($modelType);
}
}
if (null !== $constructeurIds) {
foreach ($piece->getConstructeurs()->toArray() as $existing) {
$piece->removeConstructeur($existing);
}
foreach ($constructeurIds as $cId) {
$c = $this->constructeurs->find($cId);
if (!$c) {
$this->mcpError('not_found', "Constructeur not found: {$cId}");
}
$piece->addConstructeur($c);
}
}
$this->em->flush();
return $this->jsonResponse(['id' => $piece->getId(), 'name' => $piece->getName()]);
}
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Product;
use App\Entity\Product;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\ConstructeurRepository;
use App\Repository\ModelTypeRepository;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
#[McpTool(
name: 'create_product',
description: 'Create a new product. supplierPrice must be a string (e.g. "12.50"). Requires ROLE_GESTIONNAIRE.',
)]
class CreateProductTool
{
use McpToolHelper;
public function __construct(
private readonly EntityManagerInterface $em,
private readonly Security $security,
private readonly ModelTypeRepository $modelTypes,
private readonly ConstructeurRepository $constructeurs,
) {}
/**
* @param string[] $constructeurIds
*/
public function __invoke(
string $name,
string $reference = '',
string $supplierPrice = '',
string $modelTypeId = '',
array $constructeurIds = [],
): array {
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
$product = new Product();
$product->setName($name);
if ('' !== $reference) {
$product->setReference($reference);
}
if ('' !== $supplierPrice) {
$product->setSupplierPrice($supplierPrice);
}
if ('' !== $modelTypeId) {
$modelType = $this->modelTypes->find($modelTypeId);
if (!$modelType) {
$this->mcpError('not_found', "ModelType not found: {$modelTypeId}");
}
$product->setTypeProduct($modelType);
}
foreach ($constructeurIds as $cId) {
$c = $this->constructeurs->find($cId);
if (!$c) {
$this->mcpError('not_found', "Constructeur not found: {$cId}");
}
$product->addConstructeur($c);
}
$this->em->persist($product);
$this->em->flush();
return $this->jsonResponse([
'id' => $product->getId(),
'name' => $product->getName(),
]);
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Product;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\ProductRepository;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
#[McpTool(
name: 'delete_product',
description: 'Delete a product by ID. Requires ROLE_GESTIONNAIRE.',
)]
class DeleteProductTool
{
use McpToolHelper;
public function __construct(
private readonly ProductRepository $products,
private readonly EntityManagerInterface $em,
private readonly Security $security,
) {}
public function __invoke(string $productId): array
{
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
$product = $this->products->find($productId);
if (!$product) {
$this->mcpError('not_found', "Product not found: {$productId}");
}
$this->em->remove($product);
$this->em->flush();
return $this->jsonResponse(['deleted' => true, 'id' => $productId]);
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Product;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\ProductRepository;
use Mcp\Capability\Attribute\McpTool;
#[McpTool(
name: 'get_product',
description: 'Get a single product by ID with all its details, including typeProduct and constructeurs.',
)]
class GetProductTool
{
use McpToolHelper;
public function __construct(
private readonly ProductRepository $products,
) {}
public function __invoke(string $productId): array
{
$product = $this->products->find($productId);
if (!$product) {
$this->mcpError('not_found', "Product not found: {$productId}");
}
$constructeurs = [];
foreach ($product->getConstructeurs() as $c) {
$constructeurs[] = [
'id' => $c->getId(),
'name' => $c->getName(),
];
}
$typeProduct = null;
if ($product->getTypeProduct()) {
$typeProduct = [
'id' => $product->getTypeProduct()->getId(),
'name' => $product->getTypeProduct()->getName(),
];
}
return $this->jsonResponse([
'id' => $product->getId(),
'name' => $product->getName(),
'reference' => $product->getReference(),
'supplierPrice' => $product->getSupplierPrice(),
'typeProduct' => $typeProduct,
'constructeurs' => $constructeurs,
'createdAt' => $product->getCreatedAt()->format('Y-m-d H:i:s'),
'updatedAt' => $product->getUpdatedAt()->format('Y-m-d H:i:s'),
]);
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Product;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\ProductRepository;
use Mcp\Capability\Attribute\McpTool;
#[McpTool(
name: 'list_products',
description: 'List products with pagination. Filterable by name or reference.',
)]
class ListProductsTool
{
use McpToolHelper;
public function __construct(
private readonly ProductRepository $products,
) {}
public function __invoke(int $page = 1, int $limit = 30, string $search = ''): array
{
$p = $this->paginationParams($page, $limit);
$countQb = $this->products->createQueryBuilder('pr')
->select('COUNT(pr.id)')
;
$qb = $this->products->createQueryBuilder('pr')
->select('pr.id', 'pr.name', 'pr.reference', 'pr.supplierPrice')
->orderBy('pr.name', 'ASC')
;
if ('' !== $search) {
$countQb->andWhere('LOWER(pr.name) LIKE LOWER(:search) OR LOWER(pr.reference) LIKE LOWER(:search)')
->setParameter('search', "%{$search}%")
;
$qb->andWhere('LOWER(pr.name) LIKE LOWER(:search) OR LOWER(pr.reference) LIKE LOWER(:search)')
->setParameter('search', "%{$search}%")
;
}
$total = (int) $countQb->getQuery()->getSingleScalarResult();
$items = $qb->setFirstResult($p['offset'])
->setMaxResults($p['limit'])
->getQuery()
->getArrayResult()
;
return $this->paginatedResponse($items, $total, $p['page'], $p['limit']);
}
}

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Product;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\ConstructeurRepository;
use App\Repository\ModelTypeRepository;
use App\Repository\ProductRepository;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
#[McpTool(
name: 'update_product',
description: 'Update an existing product. Only provided fields are changed. supplierPrice must be a string. Requires ROLE_GESTIONNAIRE.',
)]
class UpdateProductTool
{
use McpToolHelper;
public function __construct(
private readonly ProductRepository $products,
private readonly EntityManagerInterface $em,
private readonly Security $security,
private readonly ModelTypeRepository $modelTypes,
private readonly ConstructeurRepository $constructeurs,
) {}
/**
* @param null|string[] $constructeurIds
*/
public function __invoke(
string $productId,
?string $name = null,
?string $reference = null,
?string $supplierPrice = null,
?string $modelTypeId = null,
?array $constructeurIds = null,
): array {
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
$product = $this->products->find($productId);
if (!$product) {
$this->mcpError('not_found', "Product not found: {$productId}");
}
if (null !== $name) {
$product->setName($name);
}
if (null !== $reference) {
$product->setReference($reference);
}
if (null !== $supplierPrice) {
$product->setSupplierPrice($supplierPrice);
}
if (null !== $modelTypeId) {
if ('' === $modelTypeId) {
$product->setTypeProduct(null);
} else {
$modelType = $this->modelTypes->find($modelTypeId);
if (!$modelType) {
$this->mcpError('not_found', "ModelType not found: {$modelTypeId}");
}
$product->setTypeProduct($modelType);
}
}
if (null !== $constructeurIds) {
foreach ($product->getConstructeurs()->toArray() as $existing) {
$product->removeConstructeur($existing);
}
foreach ($constructeurIds as $cId) {
$c = $this->constructeurs->find($cId);
if (!$c) {
$this->mcpError('not_found', "Constructeur not found: {$cId}");
}
$product->addConstructeur($c);
}
}
$this->em->flush();
return $this->jsonResponse(['id' => $product->getId(), 'name' => $product->getName()]);
}
}

View File

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool;
use App\Repository\ComposantRepository;
use App\Repository\ConstructeurRepository;
use App\Repository\MachineRepository;
use App\Repository\PieceRepository;
use App\Repository\ProductRepository;
use App\Repository\SiteRepository;
use Mcp\Capability\Attribute\McpTool;
#[McpTool(
name: 'search_inventory',
description: 'Global search across all inventory entities (machines, pieces, composants, products, sites, constructeurs). Searches by name and reference (when available). Returns a flat list of matching results.',
)]
class SearchInventoryTool
{
use McpToolHelper;
private const ALLOWED_TYPES = ['machine', 'piece', 'composant', 'product', 'site', 'constructeur'];
public function __construct(
private readonly MachineRepository $machines,
private readonly PieceRepository $pieces,
private readonly ComposantRepository $composants,
private readonly ProductRepository $products,
private readonly SiteRepository $sites,
private readonly ConstructeurRepository $constructeurs,
) {}
public function __invoke(string $query, string $types = '', int $limit = 20): array
{
$query = trim($query);
if ('' === $query) {
return $this->jsonResponse([]);
}
$limit = min(100, max(1, $limit));
$searchTypes = $this->resolveTypes($types);
$results = [];
foreach ($searchTypes as $type) {
$results = array_merge($results, match ($type) {
'machine' => $this->searchWithReference($this->machines, 'm', 'machine', $query),
'piece' => $this->searchWithReference($this->pieces, 'p', 'piece', $query),
'composant' => $this->searchWithReference($this->composants, 'c', 'composant', $query),
'product' => $this->searchWithReference($this->products, 'p', 'product', $query),
'site' => $this->searchNameOnly($this->sites, 's', 'site', $query),
'constructeur' => $this->searchNameOnly($this->constructeurs, 'c', 'constructeur', $query),
});
}
$results = array_slice($results, 0, $limit);
return $this->jsonResponse($results);
}
/**
* @return list<string>
*/
private function resolveTypes(string $types): array
{
if ('' === trim($types)) {
return self::ALLOWED_TYPES;
}
$requested = array_map('trim', explode(',', strtolower($types)));
return array_values(array_intersect($requested, self::ALLOWED_TYPES));
}
private function searchWithReference(object $repository, string $alias, string $type, string $search): array
{
$qb = $repository->createQueryBuilder($alias)
->select("{$alias}.id", "{$alias}.name", "{$alias}.reference")
->where("LOWER({$alias}.name) LIKE LOWER(:search)")
->orWhere("LOWER({$alias}.reference) LIKE LOWER(:search)")
->setParameter('search', "%{$search}%")
->orderBy("{$alias}.name", 'ASC')
;
$rows = $qb->getQuery()->getArrayResult();
return array_map(fn (array $row) => [
'type' => $type,
'id' => $row['id'],
'name' => $row['name'],
'reference' => $row['reference'] ?? null,
], $rows);
}
private function searchNameOnly(object $repository, string $alias, string $type, string $search): array
{
$qb = $repository->createQueryBuilder($alias)
->select("{$alias}.id", "{$alias}.name")
->where("LOWER({$alias}.name) LIKE LOWER(:search)")
->setParameter('search', "%{$search}%")
->orderBy("{$alias}.name", 'ASC')
;
$rows = $qb->getQuery()->getArrayResult();
return array_map(fn (array $row) => [
'type' => $type,
'id' => $row['id'],
'name' => $row['name'],
'reference' => null,
], $rows);
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Site;
use App\Entity\Site;
use App\Mcp\Tool\McpToolHelper;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
#[McpTool(
name: 'create_site',
description: 'Create a new industrial site. Requires ROLE_GESTIONNAIRE.',
)]
class CreateSiteTool
{
use McpToolHelper;
public function __construct(
private readonly EntityManagerInterface $em,
private readonly Security $security,
) {}
public function __invoke(
string $name,
string $contactName = '',
string $contactPhone = '',
string $contactAddress = '',
string $contactPostalCode = '',
string $contactCity = '',
string $color = '',
): array {
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
$site = new Site();
$site->setName($name);
$site->setContactName($contactName);
$site->setContactPhone($contactPhone);
$site->setContactAddress($contactAddress);
$site->setContactPostalCode($contactPostalCode);
$site->setContactCity($contactCity);
$site->setColor($color);
$this->em->persist($site);
$this->em->flush();
return $this->jsonResponse([
'id' => $site->getId(),
'name' => $site->getName(),
]);
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Site;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\SiteRepository;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
#[McpTool(
name: 'delete_site',
description: 'Delete a site by ID. Requires ROLE_GESTIONNAIRE.',
)]
class DeleteSiteTool
{
use McpToolHelper;
public function __construct(
private readonly SiteRepository $sites,
private readonly EntityManagerInterface $em,
private readonly Security $security,
) {}
public function __invoke(string $siteId): array
{
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
$site = $this->sites->find($siteId);
if (!$site) {
$this->mcpError('not_found', "Site not found: {$siteId}");
}
$this->em->remove($site);
$this->em->flush();
return $this->jsonResponse(['deleted' => true, 'id' => $siteId]);
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Site;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\SiteRepository;
use Mcp\Capability\Attribute\McpTool;
#[McpTool(
name: 'get_site',
description: 'Get a single site by ID with all its details (name, contact info, address).',
)]
class GetSiteTool
{
use McpToolHelper;
public function __construct(
private readonly SiteRepository $sites,
) {}
public function __invoke(string $siteId): array
{
$site = $this->sites->find($siteId);
if (!$site) {
$this->mcpError('not_found', "Site not found: {$siteId}");
}
return $this->jsonResponse([
'id' => $site->getId(),
'name' => $site->getName(),
'contactName' => $site->getContactName(),
'contactPhone' => $site->getContactPhone(),
'contactAddress' => $site->getContactAddress(),
'contactPostalCode' => $site->getContactPostalCode(),
'contactCity' => $site->getContactCity(),
'color' => $site->getColor(),
'createdAt' => $site->getCreatedAt()->format('Y-m-d H:i:s'),
'updatedAt' => $site->getUpdatedAt()->format('Y-m-d H:i:s'),
]);
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Site;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\SiteRepository;
use Mcp\Capability\Attribute\McpTool;
#[McpTool(
name: 'list_sites',
description: 'List all industrial sites with pagination. Sites contain machines. Filterable by name.',
)]
class ListSitesTool
{
use McpToolHelper;
public function __construct(
private readonly SiteRepository $sites,
) {}
public function __invoke(int $page = 1, int $limit = 30, string $search = ''): array
{
$p = $this->paginationParams($page, $limit);
$countQb = $this->sites->createQueryBuilder('s')
->select('COUNT(s.id)')
;
$qb = $this->sites->createQueryBuilder('s')
->select('s.id', 's.name', 's.contactName', 's.contactCity', 's.contactPhone')
->orderBy('s.name', 'ASC')
;
if ('' !== $search) {
$countQb->andWhere('LOWER(s.name) LIKE LOWER(:search)')
->setParameter('search', "%{$search}%")
;
$qb->andWhere('LOWER(s.name) LIKE LOWER(:search)')
->setParameter('search', "%{$search}%")
;
}
$total = (int) $countQb->getQuery()->getSingleScalarResult();
$items = $qb->setFirstResult($p['offset'])
->setMaxResults($p['limit'])
->getQuery()
->getArrayResult()
;
return $this->paginatedResponse($items, $total, $p['page'], $p['limit']);
}
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Site;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\SiteRepository;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
#[McpTool(
name: 'update_site',
description: 'Update an existing site. Only provided fields are changed. Requires ROLE_GESTIONNAIRE.',
)]
class UpdateSiteTool
{
use McpToolHelper;
public function __construct(
private readonly SiteRepository $sites,
private readonly EntityManagerInterface $em,
private readonly Security $security,
) {}
public function __invoke(
string $siteId,
?string $name = null,
?string $contactName = null,
?string $contactPhone = null,
?string $contactAddress = null,
?string $contactPostalCode = null,
?string $contactCity = null,
?string $color = null,
): array {
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
$site = $this->sites->find($siteId);
if (!$site) {
$this->mcpError('not_found', "Site not found: {$siteId}");
}
if (null !== $name) {
$site->setName($name);
}
if (null !== $contactName) {
$site->setContactName($contactName);
}
if (null !== $contactPhone) {
$site->setContactPhone($contactPhone);
}
if (null !== $contactAddress) {
$site->setContactAddress($contactAddress);
}
if (null !== $contactPostalCode) {
$site->setContactPostalCode($contactPostalCode);
}
if (null !== $contactCity) {
$site->setContactCity($contactCity);
}
if (null !== $color) {
$site->setColor($color);
}
$this->em->flush();
return $this->jsonResponse(['id' => $site->getId(), 'name' => $site->getName()]);
}
}

View File

@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Slot;
use App\Entity\ComposantPieceSlot;
use App\Entity\ComposantProductSlot;
use App\Entity\ComposantSubcomponentSlot;
use App\Entity\PieceProductSlot;
use App\Mcp\Tool\McpToolHelper;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
#[McpTool(
name: 'list_slots',
description: 'List all slots for a composant or piece. Composants have piece/product/subcomponent slots. Pieces have product slots only.',
)]
class ListSlotsTool
{
use McpToolHelper;
public function __construct(
private readonly EntityManagerInterface $em,
) {}
public function __invoke(string $entityType, string $entityId): array
{
if ('composant' === $entityType) {
return $this->listComposantSlots($entityId);
}
if ('piece' === $entityType) {
return $this->listPieceSlots($entityId);
}
$this->mcpError('validation', "entityType must be 'composant' or 'piece', got '{$entityType}'.");
}
private function listComposantSlots(string $composantId): array
{
$pieceSlots = $this->em->createQueryBuilder()
->select(
'ps.id',
"'piece' AS slotType",
'ps.position',
'ps.quantity',
'tp.name AS typeName',
'sp.id AS selectedEntityId',
'sp.name AS selectedEntityName',
)
->from(ComposantPieceSlot::class, 'ps')
->leftJoin('ps.typePiece', 'tp')
->leftJoin('ps.selectedPiece', 'sp')
->where('IDENTITY(ps.composant) = :cid')
->setParameter('cid', $composantId)
->orderBy('ps.position', 'ASC')
->getQuery()
->getArrayResult()
;
$productSlots = $this->em->createQueryBuilder()
->select(
'prs.id',
"'product' AS slotType",
'prs.position',
'tp.name AS typeName',
'sp.id AS selectedEntityId',
'sp.name AS selectedEntityName',
)
->from(ComposantProductSlot::class, 'prs')
->leftJoin('prs.typeProduct', 'tp')
->leftJoin('prs.selectedProduct', 'sp')
->where('IDENTITY(prs.composant) = :cid')
->setParameter('cid', $composantId)
->orderBy('prs.position', 'ASC')
->getQuery()
->getArrayResult()
;
$subSlots = $this->em->createQueryBuilder()
->select(
'ss.id',
"'subcomponent' AS slotType",
'ss.position',
'ss.alias',
'tc.name AS typeName',
'sc.id AS selectedEntityId',
'sc.name AS selectedEntityName',
)
->from(ComposantSubcomponentSlot::class, 'ss')
->leftJoin('ss.typeComposant', 'tc')
->leftJoin('ss.selectedComposant', 'sc')
->where('IDENTITY(ss.composant) = :cid')
->setParameter('cid', $composantId)
->orderBy('ss.position', 'ASC')
->getQuery()
->getArrayResult()
;
$slots = array_merge($pieceSlots, $productSlots, $subSlots);
return $this->jsonResponse([
'entityType' => 'composant',
'entityId' => $composantId,
'slots' => $slots,
'total' => count($slots),
]);
}
private function listPieceSlots(string $pieceId): array
{
$slots = $this->em->createQueryBuilder()
->select(
'pps.id',
"'product' AS slotType",
'pps.position',
'tp.name AS typeName',
'sp.id AS selectedEntityId',
'sp.name AS selectedEntityName',
)
->from(PieceProductSlot::class, 'pps')
->leftJoin('pps.typeProduct', 'tp')
->leftJoin('pps.selectedProduct', 'sp')
->where('IDENTITY(pps.piece) = :pid')
->setParameter('pid', $pieceId)
->orderBy('pps.position', 'ASC')
->getQuery()
->getArrayResult()
;
return $this->jsonResponse([
'entityType' => 'piece',
'entityId' => $pieceId,
'slots' => $slots,
'total' => count($slots),
]);
}
}

View File

@@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Slot;
use App\Entity\Composant;
use App\Entity\ComposantPieceSlot;
use App\Entity\ComposantProductSlot;
use App\Entity\ComposantSubcomponentSlot;
use App\Entity\Piece;
use App\Entity\PieceProductSlot;
use App\Entity\Product;
use App\Mcp\Tool\McpToolHelper;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
#[McpTool(
name: 'update_slots',
description: 'Update selected entities on one or more slots. Each slot entry needs slotId, slotType, and the selected entity ID (or null to clear). Requires ROLE_GESTIONNAIRE.',
)]
class UpdateSlotsTool
{
use McpToolHelper;
public function __construct(
private readonly EntityManagerInterface $em,
private readonly Security $security,
) {}
public function __invoke(array $slots): array
{
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
$results = [];
foreach ($slots as $entry) {
$slotId = $entry['slotId'] ?? null;
$slotType = $entry['slotType'] ?? null;
if (null === $slotId || null === $slotType) {
$this->mcpError('validation', 'Each slot entry must have slotId and slotType.');
}
$results[] = match ($slotType) {
'composant_piece' => $this->updateComposantPieceSlot($slotId, $entry),
'composant_product' => $this->updateComposantProductSlot($slotId, $entry),
'composant_subcomponent' => $this->updateComposantSubcomponentSlot($slotId, $entry),
'piece_product' => $this->updatePieceProductSlot($slotId, $entry),
default => $this->mcpError('validation', "Unknown slotType '{$slotType}'."),
};
}
$this->em->flush();
return $this->jsonResponse([
'updated' => $results,
'total' => count($results),
]);
}
private function updateComposantPieceSlot(string $slotId, array $entry): array
{
$slot = $this->em->getRepository(ComposantPieceSlot::class)->find($slotId);
if (null === $slot) {
$this->mcpError('not_found', "ComposantPieceSlot '{$slotId}' not found.");
}
$selectedPieceId = $entry['selectedPieceId'] ?? null;
$selectedPiece = null;
if (null !== $selectedPieceId) {
$selectedPiece = $this->em->getRepository(Piece::class)->find($selectedPieceId);
if (null === $selectedPiece) {
$this->mcpError('not_found', "Piece '{$selectedPieceId}' not found.");
}
}
$slot->setSelectedPiece($selectedPiece);
return [
'slotId' => $slot->getId(),
'slotType' => 'composant_piece',
'selectedEntityId' => $selectedPiece?->getId(),
'selectedEntityName' => $selectedPiece?->getName(),
];
}
private function updateComposantProductSlot(string $slotId, array $entry): array
{
$slot = $this->em->getRepository(ComposantProductSlot::class)->find($slotId);
if (null === $slot) {
$this->mcpError('not_found', "ComposantProductSlot '{$slotId}' not found.");
}
$selectedProductId = $entry['selectedProductId'] ?? null;
$selectedProduct = null;
if (null !== $selectedProductId) {
$selectedProduct = $this->em->getRepository(Product::class)->find($selectedProductId);
if (null === $selectedProduct) {
$this->mcpError('not_found', "Product '{$selectedProductId}' not found.");
}
}
$slot->setSelectedProduct($selectedProduct);
return [
'slotId' => $slot->getId(),
'slotType' => 'composant_product',
'selectedEntityId' => $selectedProduct?->getId(),
'selectedEntityName' => $selectedProduct?->getName(),
];
}
private function updateComposantSubcomponentSlot(string $slotId, array $entry): array
{
$slot = $this->em->getRepository(ComposantSubcomponentSlot::class)->find($slotId);
if (null === $slot) {
$this->mcpError('not_found', "ComposantSubcomponentSlot '{$slotId}' not found.");
}
$selectedComposantId = $entry['selectedComposantId'] ?? null;
$selectedComposant = null;
if (null !== $selectedComposantId) {
$selectedComposant = $this->em->getRepository(Composant::class)->find($selectedComposantId);
if (null === $selectedComposant) {
$this->mcpError('not_found', "Composant '{$selectedComposantId}' not found.");
}
}
$slot->setSelectedComposant($selectedComposant);
return [
'slotId' => $slot->getId(),
'slotType' => 'composant_subcomponent',
'selectedEntityId' => $selectedComposant?->getId(),
'selectedEntityName' => $selectedComposant?->getName(),
];
}
private function updatePieceProductSlot(string $slotId, array $entry): array
{
$slot = $this->em->getRepository(PieceProductSlot::class)->find($slotId);
if (null === $slot) {
$this->mcpError('not_found', "PieceProductSlot '{$slotId}' not found.");
}
$selectedProductId = $entry['selectedProductId'] ?? null;
$selectedProduct = null;
if (null !== $selectedProductId) {
$selectedProduct = $this->em->getRepository(Product::class)->find($selectedProductId);
if (null === $selectedProduct) {
$this->mcpError('not_found', "Product '{$selectedProductId}' not found.");
}
}
$slot->setSelectedProduct($selectedProduct);
return [
'slotId' => $slot->getId(),
'slotType' => 'piece_product',
'selectedEntityId' => $selectedProduct?->getId(),
'selectedEntityName' => $selectedProduct?->getName(),
];
}
}

View File

@@ -25,6 +25,7 @@ use App\Entity\Profile;
use App\Entity\Site;
use App\Enum\ModelCategory;
use Doctrine\ORM\EntityManagerInterface;
use stdClass;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
abstract class AbstractApiTestCase extends ApiTestCase
@@ -85,6 +86,93 @@ abstract class AbstractApiTestCase extends ApiTestCase
return static::createClient();
}
// ── MCP helpers ──────────────────────────────────────────────────
/**
* @return array{client: Client, sessionId: string}
*/
protected function createMcpClient(string $role = 'ROLE_VIEWER'): array
{
$profile = $this->createProfile(roles: [$role], password: self::DEFAULT_PASSWORD);
return $this->initMcpSession($profile->getId(), self::DEFAULT_PASSWORD);
}
/**
* @return array{client: Client, sessionId: string}
*/
protected function initMcpSession(string $profileId, string $password): array
{
$client = static::createClient();
$response = $client->request('POST', '/_mcp', [
'headers' => [
'Content-Type' => 'application/json',
'X-Profile-Id' => $profileId,
'X-Profile-Password' => $password,
],
'body' => json_encode([
'jsonrpc' => '2.0',
'method' => 'initialize',
'params' => [
'protocolVersion' => '2025-03-26',
'capabilities' => new stdClass(),
'clientInfo' => ['name' => 'test', 'version' => '1.0'],
],
'id' => 1,
]),
]);
$sessionId = $response->getHeaders()['mcp-session-id'][0] ?? '';
$client->request('POST', '/_mcp', [
'headers' => [
'Content-Type' => 'application/json',
'X-Profile-Id' => $profileId,
'X-Profile-Password' => $password,
'Mcp-Session-Id' => $sessionId,
],
'body' => json_encode([
'jsonrpc' => '2.0',
'method' => 'notifications/initialized',
]),
]);
return ['client' => $client, 'sessionId' => $sessionId, 'profileId' => $profileId, 'password' => $password];
}
/**
* @return array<string, mixed>
*/
protected function callMcpTool(array $session, string $toolName, array $arguments = []): array
{
$response = $session['client']->request('POST', '/_mcp', [
'headers' => [
'Content-Type' => 'application/json',
'X-Profile-Id' => $session['profileId'],
'X-Profile-Password' => $session['password'],
'Mcp-Session-Id' => $session['sessionId'],
],
'body' => json_encode([
'jsonrpc' => '2.0',
'method' => 'tools/call',
'params' => [
'name' => $toolName,
'arguments' => empty($arguments) ? new stdClass() : $arguments,
],
'id' => random_int(10, 9999),
]),
]);
$data = $response->toArray(false);
if (isset($data['result']['content'][0]['text'])) {
$data['_parsed'] = json_decode($data['result']['content'][0]['text'], true);
}
return $data;
}
// ── Factory helpers ─────────────────────────────────────────────
protected function createProfile(

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Tests\Mcp\Tool\Comment;
use App\Entity\Comment;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class CommentsToolTest extends AbstractApiTestCase
{
public function testListComments(): void
{
$entityId = 'entity-'.uniqid();
$this->createComment('First comment', 'machine', $entityId);
$this->createComment('Second comment', 'machine', $entityId);
$this->createComment('Other entity', 'machine', 'other-id');
$session = $this->createMcpClient('ROLE_VIEWER');
$data = $this->callMcpTool($session, 'list_comments', [
'entityType' => 'machine',
'entityId' => $entityId,
]);
$this->assertArrayHasKey('_parsed', $data);
$this->assertSame(2, $data['_parsed']['total']);
$this->assertCount(2, $data['_parsed']['items']);
}
public function testCreateComment(): void
{
$session = $this->createMcpClient('ROLE_VIEWER');
$data = $this->callMcpTool($session, 'create_comment', [
'content' => 'A new comment',
'entityType' => 'machine',
'entityId' => 'some-machine-id',
'entityName' => 'Machine Alpha',
]);
$this->assertArrayHasKey('_parsed', $data);
$this->assertNotEmpty($data['_parsed']['id']);
$this->assertSame('A new comment', $data['_parsed']['content']);
$this->assertSame('machine', $data['_parsed']['entityType']);
$this->assertSame('open', $data['_parsed']['status']);
$this->assertSame('Machine Alpha', $data['_parsed']['entityName']);
}
public function testResolveComment(): void
{
$comment = $this->createComment('To resolve', 'piece', 'piece-123');
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
$data = $this->callMcpTool($session, 'resolve_comment', [
'commentId' => $comment->getId(),
]);
$this->assertArrayHasKey('_parsed', $data);
$this->assertSame('resolved', $data['_parsed']['status']);
$this->assertNotEmpty($data['_parsed']['resolvedByName']);
}
public function testUnresolvedCount(): void
{
$entityId = 'entity-'.uniqid();
$this->createComment('Open 1', 'machine', $entityId, 'open');
$this->createComment('Open 2', 'machine', $entityId, 'open');
$this->createComment('Resolved', 'machine', $entityId, 'resolved');
$session = $this->createMcpClient('ROLE_VIEWER');
$data = $this->callMcpTool($session, 'get_unresolved_comments_count');
$this->assertArrayHasKey('_parsed', $data);
$this->assertGreaterThanOrEqual(2, $data['_parsed']['count']);
}
private function createComment(string $content, string $entityType, string $entityId, string $status = 'open'): Comment
{
$comment = new Comment();
$comment->setContent($content);
$comment->setEntityType($entityType);
$comment->setEntityId($entityId);
$comment->setAuthorId('test-author-id');
$comment->setAuthorName('Test Author');
$comment->setStatus($status);
$em = $this->getEntityManager();
$em->persist($comment);
$em->flush();
return $comment;
}
}

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Tests\Mcp\Tool\Composant;
use App\Enum\ModelCategory;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class ComposantsCrudToolTest extends AbstractApiTestCase
{
public function testListComposants(): void
{
$this->createComposant(name: 'Composant Alpha');
$this->createComposant(name: 'Composant Beta');
$session = $this->createMcpClient('ROLE_VIEWER');
$data = $this->callMcpTool($session, 'list_composants');
$this->assertArrayHasKey('_parsed', $data);
$this->assertGreaterThanOrEqual(2, $data['_parsed']['total']);
}
public function testGetComposant(): void
{
$constructeur = $this->createConstructeur(name: 'Fournisseur Comp');
$modelType = $this->createModelType(name: 'Type Composant', code: 'TC-001', category: ModelCategory::COMPONENT);
$composant = $this->createComposant(name: 'Composant Gamma', type: $modelType);
// Add constructeur to composant
$composant->addConstructeur($constructeur);
$this->getEntityManager()->flush();
$session = $this->createMcpClient('ROLE_VIEWER');
$data = $this->callMcpTool($session, 'get_composant', ['composantId' => $composant->getId()]);
$this->assertArrayHasKey('_parsed', $data);
$this->assertSame('Composant Gamma', $data['_parsed']['name']);
$this->assertNotNull($data['_parsed']['typeComposant']);
$this->assertSame('Type Composant', $data['_parsed']['typeComposant']['name']);
$this->assertCount(1, $data['_parsed']['constructeurs']);
$this->assertSame('Fournisseur Comp', $data['_parsed']['constructeurs'][0]['name']);
}
public function testCreateComposant(): void
{
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
$data = $this->callMcpTool($session, 'create_composant', [
'name' => 'Composant Nouveau',
'reference' => 'REF-COMP',
'description' => 'Un composant de test',
'prix' => '42.99',
]);
$this->assertArrayHasKey('_parsed', $data);
$this->assertSame('Composant Nouveau', $data['_parsed']['name']);
$this->assertNotEmpty($data['_parsed']['id']);
}
public function testCreateComposantRequiresGestionnaire(): void
{
$session = $this->createMcpClient('ROLE_VIEWER');
$data = $this->callMcpTool($session, 'create_composant', ['name' => 'Forbidden']);
$this->assertArrayHasKey('error', $data, 'Should fail with VIEWER role');
}
public function testUpdateComposant(): void
{
$composant = $this->createComposant(name: 'Old Composant');
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
$data = $this->callMcpTool($session, 'update_composant', [
'composantId' => $composant->getId(),
'name' => 'Updated Composant',
'prix' => '99.00',
]);
$this->assertArrayHasKey('_parsed', $data);
$this->assertSame('Updated Composant', $data['_parsed']['name']);
}
public function testDeleteComposant(): void
{
$composant = $this->createComposant(name: 'To Delete');
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
$data = $this->callMcpTool($session, 'delete_composant', ['composantId' => $composant->getId()]);
$this->assertArrayHasKey('_parsed', $data);
$this->assertTrue($data['_parsed']['deleted']);
}
}

View File

@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Tests\Mcp\Tool\Constructeur;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class ConstructeursCrudToolTest extends AbstractApiTestCase
{
public function testListConstructeurs(): void
{
$this->createConstructeur(name: 'Constructeur Alpha');
$this->createConstructeur(name: 'Constructeur Beta');
$session = $this->createMcpClient('ROLE_VIEWER');
$data = $this->callMcpTool($session, 'list_constructeurs');
$this->assertArrayHasKey('_parsed', $data, 'MCP response: '.json_encode($data));
$this->assertGreaterThanOrEqual(2, $data['_parsed']['total']);
}
public function testGetConstructeur(): void
{
$constructeur = $this->createConstructeur(name: 'Constructeur Gamma');
$session = $this->createMcpClient('ROLE_VIEWER');
$data = $this->callMcpTool($session, 'get_constructeur', ['constructeurId' => $constructeur->getId()]);
$this->assertArrayHasKey('_parsed', $data);
$this->assertSame('Constructeur Gamma', $data['_parsed']['name']);
}
public function testCreateConstructeur(): void
{
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
$data = $this->callMcpTool($session, 'create_constructeur', [
'name' => 'Constructeur Nouveau',
'email' => 'contact@nouveau.com',
'phone' => '+33123456789',
]);
$this->assertArrayHasKey('_parsed', $data);
$this->assertSame('Constructeur Nouveau', $data['_parsed']['name']);
$this->assertNotEmpty($data['_parsed']['id']);
}
public function testCreateConstructeurRequiresGestionnaire(): void
{
$session = $this->createMcpClient('ROLE_VIEWER');
$data = $this->callMcpTool($session, 'create_constructeur', ['name' => 'Forbidden']);
$this->assertArrayHasKey('error', $data, 'Should fail with VIEWER role');
}
public function testUpdateConstructeur(): void
{
$constructeur = $this->createConstructeur(name: 'Old Name');
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
$data = $this->callMcpTool($session, 'update_constructeur', [
'constructeurId' => $constructeur->getId(),
'name' => 'New Name',
]);
$this->assertArrayHasKey('_parsed', $data);
$this->assertSame('New Name', $data['_parsed']['name']);
}
public function testDeleteConstructeur(): void
{
$constructeur = $this->createConstructeur(name: 'To Delete');
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
$data = $this->callMcpTool($session, 'delete_constructeur', ['constructeurId' => $constructeur->getId()]);
$this->assertArrayHasKey('_parsed', $data);
$this->assertTrue($data['_parsed']['deleted']);
}
}

View File

@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Tests\Mcp\Tool\CustomField;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class CustomFieldToolsTest extends AbstractApiTestCase
{
public function testListCustomFieldValues(): void
{
$machine = $this->createMachine(name: 'Machine CF');
$customField = $this->createCustomField(name: 'Serial Number', type: 'text', machine: $machine);
$this->createCustomFieldValue(customField: $customField, value: 'SN-12345', machine: $machine);
$session = $this->createMcpClient('ROLE_VIEWER');
$data = $this->callMcpTool($session, 'list_custom_field_values', [
'entityType' => 'machine',
'entityId' => $machine->getId(),
]);
$this->assertArrayHasKey('_parsed', $data);
$parsed = $data['_parsed'];
$this->assertSame('machine', $parsed['entityType']);
$this->assertSame($machine->getId(), $parsed['entityId']);
$this->assertSame(1, $parsed['total']);
$this->assertSame('SN-12345', $parsed['values'][0]['value']);
$this->assertSame('Serial Number', $parsed['values'][0]['customFieldName']);
$this->assertSame('text', $parsed['values'][0]['customFieldType']);
}
public function testUpsertCustomFieldValues(): void
{
$machine = $this->createMachine(name: 'Machine Upsert');
$customField = $this->createCustomField(name: 'Voltage', type: 'text', machine: $machine);
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
// Create
$data = $this->callMcpTool($session, 'upsert_custom_field_values', [
'entityType' => 'machine',
'entityId' => $machine->getId(),
'fields' => [
['customFieldId' => $customField->getId(), 'value' => '220V'],
],
]);
$this->assertArrayHasKey('_parsed', $data);
$parsed = $data['_parsed'];
$this->assertSame(1, $parsed['total']);
$this->assertSame('created', $parsed['results'][0]['action']);
$this->assertSame('220V', $parsed['results'][0]['value']);
$createdId = $parsed['results'][0]['id'];
// Update (upsert same field)
$data = $this->callMcpTool($session, 'upsert_custom_field_values', [
'entityType' => 'machine',
'entityId' => $machine->getId(),
'fields' => [
['customFieldId' => $customField->getId(), 'value' => '380V'],
],
]);
$parsed = $data['_parsed'];
$this->assertSame('updated', $parsed['results'][0]['action']);
$this->assertSame('380V', $parsed['results'][0]['value']);
$this->assertSame($createdId, $parsed['results'][0]['id']);
}
public function testDeleteCustomFieldValue(): void
{
$machine = $this->createMachine(name: 'Machine Delete CF');
$customField = $this->createCustomField(name: 'Weight', type: 'text', machine: $machine);
$cfv = $this->createCustomFieldValue(customField: $customField, value: '150kg', machine: $machine);
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
$data = $this->callMcpTool($session, 'delete_custom_field_value', [
'customFieldValueId' => $cfv->getId(),
]);
$this->assertArrayHasKey('_parsed', $data);
$parsed = $data['_parsed'];
$this->assertTrue($parsed['deleted']);
$this->assertSame($cfv->getId(), $parsed['id']);
// Verify it's gone
$listData = $this->callMcpTool($session, 'list_custom_field_values', [
'entityType' => 'machine',
'entityId' => $machine->getId(),
]);
$this->assertSame(0, $listData['_parsed']['total']);
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Tests\Mcp\Tool;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class DashboardStatsToolTest extends AbstractApiTestCase
{
public function testGetDashboardStatsReturnsCounters(): void
{
$site = $this->createSite();
$this->createMachine(name: 'Machine Stats 1', site: $site);
$this->createMachine(name: 'Machine Stats 2', site: $site);
$session = $this->createMcpClient('ROLE_VIEWER');
$data = $this->callMcpTool($session, 'get_dashboard_stats');
$this->assertArrayHasKey('_parsed', $data);
$stats = $data['_parsed'];
$this->assertGreaterThanOrEqual(2, $stats['machines']);
$this->assertArrayHasKey('sites', $stats);
$this->assertArrayHasKey('unresolvedComments', $stats);
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Tests\Mcp\Tool\Document;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class DocumentToolsTest extends AbstractApiTestCase
{
public function testListDocuments(): void
{
$site = $this->createSite(name: 'Doc Site');
$session = $this->createMcpClient('ROLE_VIEWER');
$data = $this->callMcpTool($session, 'list_documents', [
'entityType' => 'site',
'entityId' => $site->getId(),
]);
$this->assertArrayHasKey('_parsed', $data);
$this->assertSame('site', $data['_parsed']['entityType']);
$this->assertSame($site->getId(), $data['_parsed']['entityId']);
$this->assertIsArray($data['_parsed']['items']);
$this->assertSame(0, $data['_parsed']['total']);
}
public function testDeleteDocumentRequiresGestionnaire(): void
{
$session = $this->createMcpClient('ROLE_VIEWER');
$data = $this->callMcpTool($session, 'delete_document', [
'documentId' => 'nonexistent-id',
]);
$this->assertArrayHasKey('error', $data);
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Tests\Mcp\Tool;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class HistoryToolsTest extends AbstractApiTestCase
{
public function testGetActivityLog(): void
{
$session = $this->createMcpClient('ROLE_VIEWER');
$data = $this->callMcpTool($session, 'get_activity_log');
$this->assertArrayHasKey('_parsed', $data);
$this->assertArrayHasKey('items', $data['_parsed']);
$this->assertArrayHasKey('total', $data['_parsed']);
$this->assertArrayHasKey('page', $data['_parsed']);
$this->assertArrayHasKey('limit', $data['_parsed']);
$this->assertArrayHasKey('pageCount', $data['_parsed']);
$this->assertIsArray($data['_parsed']['items']);
}
public function testGetEntityHistory(): void
{
$machine = $this->createMachine(name: 'History Machine');
$session = $this->createMcpClient('ROLE_VIEWER');
$data = $this->callMcpTool($session, 'get_entity_history', [
'entityType' => 'machine',
'entityId' => $machine->getId(),
]);
$this->assertArrayHasKey('_parsed', $data);
$this->assertArrayHasKey('items', $data['_parsed']);
$this->assertArrayHasKey('total', $data['_parsed']);
$this->assertIsArray($data['_parsed']['items']);
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Tests\Mcp\Tool\Machine;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class MachineLinksToolTest extends AbstractApiTestCase
{
public function testListMachineLinks(): void
{
$machine = $this->createMachine(name: 'Machine Links Test');
$composant = $this->createComposant(name: 'Comp A');
$piece = $this->createPiece(name: 'Piece A');
$compLink = $this->createMachineComponentLink($machine, $composant);
$pieceLink = $this->createMachinePieceLink($machine, $piece, parentLink: $compLink, quantity: 3);
$session = $this->createMcpClient('ROLE_VIEWER');
$data = $this->callMcpTool($session, 'list_machine_links', ['machineId' => $machine->getId()]);
$this->assertArrayHasKey('_parsed', $data);
$parsed = $data['_parsed'];
$this->assertSame($machine->getId(), $parsed['machineId']);
$this->assertCount(1, $parsed['componentLinks']);
$this->assertCount(1, $parsed['pieceLinks']);
$this->assertSame($compLink->getId(), $parsed['componentLinks'][0]['id']);
$this->assertSame($pieceLink->getId(), $parsed['pieceLinks'][0]['id']);
$this->assertSame(3, $parsed['pieceLinks'][0]['quantity']);
}
public function testAddMachineComponentLink(): void
{
$machine = $this->createMachine(name: 'Machine Add Test');
$composant = $this->createComposant(name: 'Comp B');
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
$data = $this->callMcpTool($session, 'add_machine_links', [
'machineId' => $machine->getId(),
'links' => [
[
'type' => 'composant',
'entityId' => $composant->getId(),
'nameOverride' => 'Custom Name',
],
],
]);
$this->assertArrayHasKey('_parsed', $data);
$created = $data['_parsed']['created'];
$this->assertCount(1, $created);
$this->assertSame('composant', $created[0]['type']);
$this->assertSame($composant->getId(), $created[0]['entityId']);
$this->assertNotEmpty($created[0]['id']);
}
public function testRemoveMachineLink(): void
{
$machine = $this->createMachine(name: 'Machine Remove Test');
$composant = $this->createComposant(name: 'Comp C');
$link = $this->createMachineComponentLink($machine, $composant);
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
$data = $this->callMcpTool($session, 'remove_machine_link', [
'linkId' => $link->getId(),
'linkType' => 'composant',
]);
$this->assertArrayHasKey('_parsed', $data);
$this->assertTrue($data['_parsed']['deleted']);
// Verify the link is gone by listing
$listData = $this->callMcpTool($session, 'list_machine_links', ['machineId' => $machine->getId()]);
$this->assertCount(0, $listData['_parsed']['componentLinks']);
}
}

View File

@@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
namespace App\Tests\Mcp\Tool\Machine;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class MachineStructureToolTest extends AbstractApiTestCase
{
public function testGetMachineStructure(): void
{
$site = $this->createSite(name: 'Site Structure');
$machine = $this->createMachine(name: 'Machine Structure', site: $site, reference: 'REF-STRUCT');
$composant = $this->createComposant(name: 'Composant Alpha');
$piece = $this->createPiece(name: 'Piece Alpha', reference: 'REF-P1');
$product = $this->createProduct(name: 'Product Alpha', reference: 'REF-PR1');
$componentLink = $this->createMachineComponentLink($machine, $composant);
$this->createMachinePieceLink($machine, $piece, $componentLink, quantity: 3);
$this->createMachineProductLink($machine, $product);
$session = $this->createMcpClient('ROLE_VIEWER');
$data = $this->callMcpTool($session, 'get_machine_structure', [
'machineId' => $machine->getId(),
]);
$this->assertArrayHasKey('_parsed', $data);
$parsed = $data['_parsed'];
// Machine info
$this->assertSame('Machine Structure', $parsed['machine']['name']);
$this->assertSame('REF-STRUCT', $parsed['machine']['reference']);
$this->assertSame($site->getId(), $parsed['machine']['siteId']);
// Component links
$this->assertCount(1, $parsed['componentLinks']);
$this->assertSame($composant->getId(), $parsed['componentLinks'][0]['composantId']);
$this->assertSame('Composant Alpha', $parsed['componentLinks'][0]['composant']['name']);
// Piece links
$this->assertCount(1, $parsed['pieceLinks']);
$this->assertSame($piece->getId(), $parsed['pieceLinks'][0]['pieceId']);
$this->assertSame('Piece Alpha', $parsed['pieceLinks'][0]['piece']['name']);
$this->assertSame(3, $parsed['pieceLinks'][0]['quantity']);
// Product links
$this->assertCount(1, $parsed['productLinks']);
$this->assertSame($product->getId(), $parsed['productLinks'][0]['productId']);
$this->assertSame('Product Alpha', $parsed['productLinks'][0]['product']['name']);
}
public function testGetMachineStructureNotFound(): void
{
$session = $this->createMcpClient('ROLE_VIEWER');
$data = $this->callMcpTool($session, 'get_machine_structure', [
'machineId' => 'nonexistent-id',
]);
$this->assertArrayHasKey('error', $data);
}
public function testGetMachineStructureWithOverrides(): void
{
$site = $this->createSite(name: 'Site Overrides');
$machine = $this->createMachine(name: 'Machine Overrides', site: $site);
$composant = $this->createComposant(name: 'Composant Override');
$componentLink = $this->createMachineComponentLink($machine, $composant);
$componentLink->setNameOverride('Custom Name');
$componentLink->setReferenceOverride('CUSTOM-REF');
$this->getEntityManager()->flush();
$session = $this->createMcpClient('ROLE_VIEWER');
$data = $this->callMcpTool($session, 'get_machine_structure', [
'machineId' => $machine->getId(),
]);
$this->assertArrayHasKey('_parsed', $data);
$overrides = $data['_parsed']['componentLinks'][0]['overrides'];
$this->assertSame('Custom Name', $overrides['name']);
$this->assertSame('CUSTOM-REF', $overrides['reference']);
}
public function testCloneMachine(): void
{
$site = $this->createSite(name: 'Site Source');
$targetSite = $this->createSite(name: 'Site Target');
$machine = $this->createMachine(name: 'Machine Source', site: $site, reference: 'REF-SRC');
$composant = $this->createComposant(name: 'Composant Clone');
$piece = $this->createPiece(name: 'Piece Clone');
$product = $this->createProduct(name: 'Product Clone');
$componentLink = $this->createMachineComponentLink($machine, $composant);
$this->createMachinePieceLink($machine, $piece, $componentLink, quantity: 2);
$this->createMachineProductLink($machine, $product);
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
$data = $this->callMcpTool($session, 'clone_machine', [
'machineId' => $machine->getId(),
'name' => 'Machine Cloned',
'siteId' => $targetSite->getId(),
'reference' => 'REF-CLN',
]);
$this->assertArrayHasKey('_parsed', $data);
$parsed = $data['_parsed'];
$this->assertSame('Machine Cloned', $parsed['name']);
$this->assertSame('REF-CLN', $parsed['reference']);
$this->assertSame($targetSite->getId(), $parsed['siteId']);
$this->assertSame($machine->getId(), $parsed['clonedFrom']);
$this->assertNotSame($machine->getId(), $parsed['id']);
// Verify the cloned machine has links by fetching its structure
$structureData = $this->callMcpTool($session, 'get_machine_structure', [
'machineId' => $parsed['id'],
]);
$this->assertArrayHasKey('_parsed', $structureData);
$structure = $structureData['_parsed'];
$this->assertCount(1, $structure['componentLinks']);
$this->assertSame($composant->getId(), $structure['componentLinks'][0]['composantId']);
$this->assertCount(1, $structure['pieceLinks']);
$this->assertCount(1, $structure['productLinks']);
}
public function testCloneMachineRequiresGestionnaire(): void
{
$site = $this->createSite(name: 'Site Forbidden');
$machine = $this->createMachine(name: 'Machine Forbidden', site: $site);
$session = $this->createMcpClient('ROLE_VIEWER');
$data = $this->callMcpTool($session, 'clone_machine', [
'machineId' => $machine->getId(),
'name' => 'Should Fail',
'siteId' => $site->getId(),
]);
$this->assertArrayHasKey('error', $data, 'Should fail with VIEWER role');
}
public function testCloneMachineNotFound(): void
{
$site = $this->createSite(name: 'Site Clone NF');
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
$data = $this->callMcpTool($session, 'clone_machine', [
'machineId' => 'nonexistent-id',
'name' => 'Should Fail',
'siteId' => $site->getId(),
]);
$this->assertArrayHasKey('error', $data);
}
}

View File

@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace App\Tests\Mcp\Tool\Machine;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class MachinesCrudToolTest extends AbstractApiTestCase
{
public function testListMachines(): void
{
$site = $this->createSite(name: 'Site Usine');
$this->createMachine(name: 'Machine Alpha', site: $site);
$this->createMachine(name: 'Machine Beta', site: $site);
$session = $this->createMcpClient('ROLE_VIEWER');
$data = $this->callMcpTool($session, 'list_machines');
$this->assertArrayHasKey('_parsed', $data);
$this->assertGreaterThanOrEqual(2, $data['_parsed']['total']);
}
public function testGetMachine(): void
{
$site = $this->createSite(name: 'Site Principal');
$constructeur = $this->createConstructeur(name: 'Fournisseur M');
$machine = $this->createMachine(name: 'Machine Gamma', site: $site, reference: 'REF-M001');
// Add constructeur to machine
$machine->addConstructeur($constructeur);
$this->getEntityManager()->flush();
$session = $this->createMcpClient('ROLE_VIEWER');
$data = $this->callMcpTool($session, 'get_machine', ['machineId' => $machine->getId()]);
$this->assertArrayHasKey('_parsed', $data);
$this->assertSame('Machine Gamma', $data['_parsed']['name']);
$this->assertSame('REF-M001', $data['_parsed']['reference']);
$this->assertNotNull($data['_parsed']['site']);
$this->assertSame('Site Principal', $data['_parsed']['site']['name']);
$this->assertCount(1, $data['_parsed']['constructeurs']);
$this->assertSame('Fournisseur M', $data['_parsed']['constructeurs'][0]['name']);
}
public function testCreateMachine(): void
{
$site = $this->createSite(name: 'Site Création');
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
$data = $this->callMcpTool($session, 'create_machine', [
'name' => 'Machine Nouvelle',
'siteId' => $site->getId(),
'reference' => 'REF-NEW',
'prix' => '42.99',
]);
$this->assertArrayHasKey('_parsed', $data);
$this->assertSame('Machine Nouvelle', $data['_parsed']['name']);
$this->assertNotEmpty($data['_parsed']['id']);
}
public function testCreateMachineRequiresGestionnaire(): void
{
$site = $this->createSite(name: 'Site Forbidden');
$session = $this->createMcpClient('ROLE_VIEWER');
$data = $this->callMcpTool($session, 'create_machine', [
'name' => 'Forbidden',
'siteId' => $site->getId(),
]);
$this->assertArrayHasKey('error', $data, 'Should fail with VIEWER role');
}
public function testUpdateMachine(): void
{
$site = $this->createSite(name: 'Site Update');
$machine = $this->createMachine(name: 'Old Machine', site: $site);
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
$data = $this->callMcpTool($session, 'update_machine', [
'machineId' => $machine->getId(),
'name' => 'Updated Machine',
'prix' => '99.00',
]);
$this->assertArrayHasKey('_parsed', $data);
$this->assertSame('Updated Machine', $data['_parsed']['name']);
}
public function testDeleteMachine(): void
{
$site = $this->createSite(name: 'Site Delete');
$machine = $this->createMachine(name: 'To Delete', site: $site);
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
$data = $this->callMcpTool($session, 'delete_machine', ['machineId' => $machine->getId()]);
$this->assertArrayHasKey('_parsed', $data);
$this->assertTrue($data['_parsed']['deleted']);
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Tests\Mcp\Tool\ModelType;
use App\Enum\ModelCategory;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class ModelTypeToolsTest extends AbstractApiTestCase
{
public function testListModelTypes(): void
{
$this->createModelType(name: 'MT Alpha', code: 'MTA-'.bin2hex(random_bytes(3)), category: ModelCategory::COMPONENT);
$this->createModelType(name: 'MT Beta', code: 'MTB-'.bin2hex(random_bytes(3)), category: ModelCategory::PIECE);
$session = $this->createMcpClient('ROLE_VIEWER');
$data = $this->callMcpTool($session, 'list_model_types');
$this->assertArrayHasKey('_parsed', $data);
$this->assertGreaterThanOrEqual(2, $data['_parsed']['total']);
}
public function testGetModelType(): void
{
$mt = $this->createModelType(name: 'MT Detail', code: 'MTD-'.bin2hex(random_bytes(3)));
$session = $this->createMcpClient('ROLE_VIEWER');
$data = $this->callMcpTool($session, 'get_model_type', ['modelTypeId' => $mt->getId()]);
$this->assertArrayHasKey('_parsed', $data);
$this->assertSame('MT Detail', $data['_parsed']['name']);
$this->assertSame('COMPONENT', $data['_parsed']['category']);
$this->assertIsArray($data['_parsed']['skeletonPieceRequirements']);
}
public function testCreateModelType(): void
{
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
$data = $this->callMcpTool($session, 'create_model_type', [
'name' => 'MT Nouveau',
'category' => 'composant',
'code' => 'MTN-'.bin2hex(random_bytes(3)),
]);
$this->assertArrayHasKey('_parsed', $data);
$this->assertSame('MT Nouveau', $data['_parsed']['name']);
$this->assertSame('COMPONENT', $data['_parsed']['category']);
$this->assertNotEmpty($data['_parsed']['id']);
}
public function testDeleteModelType(): void
{
$mt = $this->createModelType(name: 'MT To Delete', code: 'MTDEL-'.bin2hex(random_bytes(3)));
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
$data = $this->callMcpTool($session, 'delete_model_type', ['modelTypeId' => $mt->getId()]);
$this->assertArrayHasKey('_parsed', $data);
$this->assertTrue($data['_parsed']['deleted']);
}
}

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Tests\Mcp\Tool\Piece;
use App\Enum\ModelCategory;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class PiecesCrudToolTest extends AbstractApiTestCase
{
public function testListPieces(): void
{
$this->createPiece(name: 'Piece Alpha');
$this->createPiece(name: 'Piece Beta', reference: 'REF-BETA');
$session = $this->createMcpClient('ROLE_VIEWER');
$data = $this->callMcpTool($session, 'list_pieces');
$this->assertArrayHasKey('_parsed', $data);
$this->assertGreaterThanOrEqual(2, $data['_parsed']['total']);
}
public function testGetPiece(): void
{
$constructeur = $this->createConstructeur(name: 'Fournisseur Piece');
$modelType = $this->createModelType(name: 'Type Piece', code: 'TP-001', category: ModelCategory::PIECE);
$piece = $this->createPiece(name: 'Piece Gamma', reference: 'REF-001', type: $modelType);
// Add constructeur to piece
$piece->addConstructeur($constructeur);
$this->getEntityManager()->flush();
$session = $this->createMcpClient('ROLE_VIEWER');
$data = $this->callMcpTool($session, 'get_piece', ['pieceId' => $piece->getId()]);
$this->assertArrayHasKey('_parsed', $data);
$this->assertSame('Piece Gamma', $data['_parsed']['name']);
$this->assertSame('REF-001', $data['_parsed']['reference']);
$this->assertNotNull($data['_parsed']['typePiece']);
$this->assertSame('Type Piece', $data['_parsed']['typePiece']['name']);
$this->assertCount(1, $data['_parsed']['constructeurs']);
$this->assertSame('Fournisseur Piece', $data['_parsed']['constructeurs'][0]['name']);
}
public function testCreatePiece(): void
{
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
$data = $this->callMcpTool($session, 'create_piece', [
'name' => 'Piece Nouveau',
'reference' => 'REF-NEW',
'prix' => '42.99',
]);
$this->assertArrayHasKey('_parsed', $data);
$this->assertSame('Piece Nouveau', $data['_parsed']['name']);
$this->assertNotEmpty($data['_parsed']['id']);
}
public function testCreatePieceRequiresGestionnaire(): void
{
$session = $this->createMcpClient('ROLE_VIEWER');
$data = $this->callMcpTool($session, 'create_piece', ['name' => 'Forbidden']);
$this->assertArrayHasKey('error', $data, 'Should fail with VIEWER role');
}
public function testUpdatePiece(): void
{
$piece = $this->createPiece(name: 'Old Piece');
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
$data = $this->callMcpTool($session, 'update_piece', [
'pieceId' => $piece->getId(),
'name' => 'Updated Piece',
'prix' => '99.00',
]);
$this->assertArrayHasKey('_parsed', $data);
$this->assertSame('Updated Piece', $data['_parsed']['name']);
}
public function testDeletePiece(): void
{
$piece = $this->createPiece(name: 'To Delete');
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
$data = $this->callMcpTool($session, 'delete_piece', ['pieceId' => $piece->getId()]);
$this->assertArrayHasKey('_parsed', $data);
$this->assertTrue($data['_parsed']['deleted']);
}
}

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Tests\Mcp\Tool\Product;
use App\Enum\ModelCategory;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class ProductsCrudToolTest extends AbstractApiTestCase
{
public function testListProducts(): void
{
$this->createProduct(name: 'Product Alpha');
$this->createProduct(name: 'Product Beta');
$session = $this->createMcpClient('ROLE_VIEWER');
$data = $this->callMcpTool($session, 'list_products');
$this->assertArrayHasKey('_parsed', $data);
$this->assertGreaterThanOrEqual(2, $data['_parsed']['total']);
}
public function testGetProduct(): void
{
$constructeur = $this->createConstructeur(name: 'Fournisseur A');
$modelType = $this->createModelType(name: 'Type Produit', code: 'TP-001', category: ModelCategory::PRODUCT);
$product = $this->createProduct(name: 'Product Gamma', reference: 'REF-001', type: $modelType);
// Add constructeur to product
$product->addConstructeur($constructeur);
$this->getEntityManager()->flush();
$session = $this->createMcpClient('ROLE_VIEWER');
$data = $this->callMcpTool($session, 'get_product', ['productId' => $product->getId()]);
$this->assertArrayHasKey('_parsed', $data);
$this->assertSame('Product Gamma', $data['_parsed']['name']);
$this->assertSame('REF-001', $data['_parsed']['reference']);
$this->assertNotNull($data['_parsed']['typeProduct']);
$this->assertSame('Type Produit', $data['_parsed']['typeProduct']['name']);
$this->assertCount(1, $data['_parsed']['constructeurs']);
$this->assertSame('Fournisseur A', $data['_parsed']['constructeurs'][0]['name']);
}
public function testCreateProduct(): void
{
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
$data = $this->callMcpTool($session, 'create_product', [
'name' => 'Product Nouveau',
'reference' => 'REF-NEW',
'supplierPrice' => '42.99',
]);
$this->assertArrayHasKey('_parsed', $data);
$this->assertSame('Product Nouveau', $data['_parsed']['name']);
$this->assertNotEmpty($data['_parsed']['id']);
}
public function testCreateProductRequiresGestionnaire(): void
{
$session = $this->createMcpClient('ROLE_VIEWER');
$data = $this->callMcpTool($session, 'create_product', ['name' => 'Forbidden']);
$this->assertArrayHasKey('error', $data, 'Should fail with VIEWER role');
}
public function testUpdateProduct(): void
{
$product = $this->createProduct(name: 'Old Product');
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
$data = $this->callMcpTool($session, 'update_product', [
'productId' => $product->getId(),
'name' => 'Updated Product',
'supplierPrice' => '99.00',
]);
$this->assertArrayHasKey('_parsed', $data);
$this->assertSame('Updated Product', $data['_parsed']['name']);
}
public function testDeleteProduct(): void
{
$product = $this->createProduct(name: 'To Delete');
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
$data = $this->callMcpTool($session, 'delete_product', ['productId' => $product->getId()]);
$this->assertArrayHasKey('_parsed', $data);
$this->assertTrue($data['_parsed']['deleted']);
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Tests\Mcp\Tool;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class SearchInventoryToolTest extends AbstractApiTestCase
{
public function testSearchFindsAcrossEntities(): void
{
$this->createMachine(name: 'Alpha Machine');
$this->createPiece(name: 'Alpha Piece');
$this->createSite(name: 'Alpha Site');
$session = $this->createMcpClient('ROLE_VIEWER');
$data = $this->callMcpTool($session, 'search_inventory', ['query' => 'Alpha']);
$this->assertArrayHasKey('_parsed', $data);
$results = $data['_parsed'];
$this->assertIsArray($results);
$types = array_unique(array_column($results, 'type'));
$this->assertContains('machine', $types);
$this->assertContains('piece', $types);
$this->assertContains('site', $types);
foreach ($results as $result) {
$this->assertArrayHasKey('type', $result);
$this->assertArrayHasKey('id', $result);
$this->assertArrayHasKey('name', $result);
$this->assertArrayHasKey('reference', $result);
$this->assertStringContainsStringIgnoringCase('Alpha', $result['name']);
}
}
public function testSearchFiltersByType(): void
{
$this->createMachine(name: 'Beta Machine');
$this->createPiece(name: 'Beta Piece');
$this->createSite(name: 'Beta Site');
$session = $this->createMcpClient('ROLE_VIEWER');
$data = $this->callMcpTool($session, 'search_inventory', [
'query' => 'Beta',
'types' => 'machine',
]);
$this->assertArrayHasKey('_parsed', $data);
$results = $data['_parsed'];
$this->assertIsArray($results);
$this->assertNotEmpty($results);
$types = array_unique(array_column($results, 'type'));
$this->assertSame(['machine'], $types);
}
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Tests\Mcp\Tool\Site;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class SitesCrudToolTest extends AbstractApiTestCase
{
public function testListSites(): void
{
$this->createSite(name: 'Site Alpha');
$this->createSite(name: 'Site Beta');
$session = $this->createMcpClient('ROLE_VIEWER');
$data = $this->callMcpTool($session, 'list_sites');
$this->assertArrayHasKey('_parsed', $data);
$this->assertGreaterThanOrEqual(2, $data['_parsed']['total']);
}
public function testGetSite(): void
{
$site = $this->createSite(name: 'Site Gamma');
$session = $this->createMcpClient('ROLE_VIEWER');
$data = $this->callMcpTool($session, 'get_site', ['siteId' => $site->getId()]);
$this->assertArrayHasKey('_parsed', $data);
$this->assertSame('Site Gamma', $data['_parsed']['name']);
}
public function testCreateSite(): void
{
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
$data = $this->callMcpTool($session, 'create_site', [
'name' => 'Site Nouveau',
'contactCity' => 'Paris',
]);
$this->assertArrayHasKey('_parsed', $data);
$this->assertSame('Site Nouveau', $data['_parsed']['name']);
$this->assertNotEmpty($data['_parsed']['id']);
}
public function testCreateSiteRequiresGestionnaire(): void
{
$session = $this->createMcpClient('ROLE_VIEWER');
$data = $this->callMcpTool($session, 'create_site', ['name' => 'Forbidden']);
$this->assertArrayHasKey('error', $data);
}
public function testUpdateSite(): void
{
$site = $this->createSite(name: 'Old Name');
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
$data = $this->callMcpTool($session, 'update_site', [
'siteId' => $site->getId(),
'name' => 'New Name',
]);
$this->assertArrayHasKey('_parsed', $data);
$this->assertSame('New Name', $data['_parsed']['name']);
}
public function testDeleteSite(): void
{
$site = $this->createSite(name: 'To Delete');
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
$data = $this->callMcpTool($session, 'delete_site', ['siteId' => $site->getId()]);
$this->assertArrayHasKey('_parsed', $data);
$this->assertTrue($data['_parsed']['deleted']);
}
}

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Tests\Mcp\Tool\Slot;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class SlotsToolTest extends AbstractApiTestCase
{
public function testListSlotsForComposant(): void
{
$composant = $this->createComposant(name: 'Comp Slots');
$this->createComposantPieceSlot(composant: $composant, position: 0);
$this->createComposantProductSlot(composant: $composant, position: 1);
$this->createComposantSubcomponentSlot(composant: $composant, alias: 'Sub A', position: 2);
$session = $this->createMcpClient('ROLE_VIEWER');
$data = $this->callMcpTool($session, 'list_slots', [
'entityType' => 'composant',
'entityId' => $composant->getId(),
]);
$this->assertArrayHasKey('_parsed', $data);
$parsed = $data['_parsed'];
$this->assertSame('composant', $parsed['entityType']);
$this->assertSame(3, $parsed['total']);
$slotTypes = array_column($parsed['slots'], 'slotType');
$this->assertContains('piece', $slotTypes);
$this->assertContains('product', $slotTypes);
$this->assertContains('subcomponent', $slotTypes);
}
public function testListSlotsForPiece(): void
{
$piece = $this->createPiece(name: 'Piece Slots');
$this->createPieceProductSlot(piece: $piece, position: 0);
$this->createPieceProductSlot(piece: $piece, position: 1);
$session = $this->createMcpClient('ROLE_VIEWER');
$data = $this->callMcpTool($session, 'list_slots', [
'entityType' => 'piece',
'entityId' => $piece->getId(),
]);
$this->assertArrayHasKey('_parsed', $data);
$parsed = $data['_parsed'];
$this->assertSame('piece', $parsed['entityType']);
$this->assertSame(2, $parsed['total']);
foreach ($parsed['slots'] as $slot) {
$this->assertSame('product', $slot['slotType']);
}
}
public function testUpdateSlotSelectsPiece(): void
{
$composant = $this->createComposant(name: 'Comp Update');
$piece = $this->createPiece(name: 'Selected Piece');
$slot = $this->createComposantPieceSlot(composant: $composant, position: 0);
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
$data = $this->callMcpTool($session, 'update_slots', [
'slots' => [
[
'slotId' => $slot->getId(),
'slotType' => 'composant_piece',
'selectedPieceId' => $piece->getId(),
],
],
]);
$this->assertArrayHasKey('_parsed', $data);
$parsed = $data['_parsed'];
$this->assertSame(1, $parsed['total']);
$this->assertSame($piece->getId(), $parsed['updated'][0]['selectedEntityId']);
$this->assertSame('Selected Piece', $parsed['updated'][0]['selectedEntityName']);
}
}