diff --git a/.mcp.json b/.mcp.json
new file mode 100644
index 0000000..1b7c056
--- /dev/null
+++ b/.mcp.json
@@ -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"
+ ]
+ }
+ }
+}
diff --git a/docs/mcp/README.md b/docs/mcp/README.md
new file mode 100644
index 0000000..4aa7e72
--- /dev/null
+++ b/docs/mcp/README.md
@@ -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 |
diff --git a/docs/superpowers/plans/2026-03-16-mcp-server.md b/docs/superpowers/plans/2026-03-16-mcp-server.md
new file mode 100644
index 0000000..8f75603
--- /dev/null
+++ b/docs/superpowers/plans/2026-03-16-mcp-server.md
@@ -0,0 +1,1472 @@
+# MCP Server Implementation Plan
+
+> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add a Model Context Protocol (MCP) server to the Inventory Symfony app, exposing ~55 tools for full CRUD + business operations on all entities, with dual transport (stdio + HTTP) and pass-through authentication.
+
+**Architecture:** `symfony/mcp-bundle` v0.6.0 integrated into the existing Symfony 8 app. A dedicated firewall (`^/_mcp`) with `McpHeaderAuthenticator` handles pass-through auth via `X-Profile-Id`/`X-Profile-Password` headers. Tools are PHP classes with `#[McpTool]` attributes that access Doctrine repositories directly. Resources expose schema metadata.
+
+**Tech Stack:** PHP 8.4, Symfony 8.0, `symfony/mcp-bundle` ^0.6, `symfony/rate-limiter`, PostgreSQL 16, Docker Compose
+
+**Spec:** `docs/superpowers/specs/2026-03-16-mcp-server-design.md`
+
+---
+
+## Pre-requisite: Create feature branch
+
+```bash
+git checkout -b feat/mcp-server develop
+```
+
+---
+
+## Chunk 1: Foundation — Bundle Install, Auth, PoC Tool
+
+### Task 1: Install dependencies and configure the MCP bundle
+
+**Files:**
+- Modify: `composer.json`
+- Create: `config/packages/mcp.yaml`
+- Modify: `config/routes.yaml`
+- Create: `config/packages/rate_limiter.yaml`
+
+- [ ] **Step 1: Install symfony/mcp-bundle and symfony/rate-limiter**
+
+```bash
+docker exec -u www-data php-inventory-apache composer require symfony/mcp-bundle symfony/rate-limiter
+```
+
+- [ ] **Step 2: Create MCP bundle config**
+
+Create `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
+```
+
+- [ ] **Step 3: Add MCP route**
+
+Add to `config/routes.yaml` (after existing routes):
+
+```yaml
+mcp:
+ resource: .
+ type: mcp
+```
+
+- [ ] **Step 4: Create rate limiter config**
+
+Create `config/packages/rate_limiter.yaml`:
+
+```yaml
+framework:
+ rate_limiter:
+ mcp_auth:
+ policy: sliding_window
+ limit: 5
+ interval: '1 minute'
+```
+
+- [ ] **Step 5: Add MCP logging channel**
+
+Create `config/packages/monolog.yaml` (if not exists) or add to it:
+
+```yaml
+monolog:
+ channels: ['mcp']
+ handlers:
+ mcp:
+ type: rotating_file
+ path: '%kernel.logs_dir%/mcp.log'
+ level: info
+ channels: ['mcp']
+ max_files: 30
+```
+
+- [ ] **Step 6: Verify Symfony cache clear succeeds**
+
+```bash
+docker exec -u www-data php-inventory-apache php bin/console cache:clear
+```
+
+Expected: no errors, MCP bundle loaded.
+
+- [ ] **Step 7: Verify MCP routes are registered**
+
+```bash
+docker exec -u www-data php-inventory-apache php bin/console debug:router | grep mcp
+```
+
+Expected: `/_mcp` route listed.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add composer.json composer.lock symfony.lock config/packages/mcp.yaml config/packages/rate_limiter.yaml config/packages/monolog.yaml config/routes.yaml
+git commit -m "feat(mcp) : install symfony/mcp-bundle and configure transports"
+```
+
+---
+
+### Task 2: Create McpHeaderAuthenticator
+
+**Files:**
+- Create: `src/Mcp/Security/McpHeaderAuthenticator.php`
+- Modify: `config/packages/security.yaml`
+
+- [ ] **Step 1: Write the authenticator test**
+
+Create `tests/Mcp/Security/McpHeaderAuthenticatorTest.php`:
+
+```php
+createUnauthenticatedClient();
+
+ $client->request('POST', '/_mcp', [
+ 'headers' => ['Content-Type' => 'application/json'],
+ 'body' => json_encode([
+ 'jsonrpc' => '2.0',
+ 'method' => 'initialize',
+ 'params' => [
+ 'protocolVersion' => '2025-03-26',
+ 'capabilities' => new \stdClass(),
+ 'clientInfo' => ['name' => 'test', 'version' => '1.0'],
+ ],
+ 'id' => 1,
+ ]),
+ ]);
+
+ // Without credentials, should get 401
+ $this->assertResponseStatusCodeSame(401);
+ }
+
+ public function testMcpEndpointRejectsInvalidPassword(): void
+ {
+ $profile = $this->createProfile(
+ firstName: 'McpTest',
+ role: 'ROLE_VIEWER',
+ password: 'correct-password',
+ );
+
+ $client = $this->createUnauthenticatedClient();
+
+ $client->request('POST', '/_mcp', [
+ 'headers' => [
+ 'Content-Type' => 'application/json',
+ 'X-Profile-Id' => $profile->getId(),
+ 'X-Profile-Password' => 'wrong-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,
+ ]),
+ ]);
+
+ $this->assertResponseStatusCodeSame(401);
+ }
+
+ public function testMcpEndpointAcceptsValidCredentials(): void
+ {
+ $profile = $this->createProfile(
+ firstName: 'McpTest',
+ role: 'ROLE_VIEWER',
+ password: 'valid-password',
+ );
+
+ $client = $this->createUnauthenticatedClient();
+
+ $client->request('POST', '/_mcp', [
+ 'headers' => [
+ 'Content-Type' => 'application/json',
+ 'X-Profile-Id' => $profile->getId(),
+ 'X-Profile-Password' => 'valid-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,
+ ]),
+ ]);
+
+ // Should NOT be 401 — may be 200 or other MCP response
+ $this->assertResponseStatusCodeSame(200);
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+```bash
+make test FILES=tests/Mcp/Security/McpHeaderAuthenticatorTest.php
+```
+
+Expected: FAIL — class `McpHeaderAuthenticator` does not exist.
+
+- [ ] **Step 3: Create the authenticator**
+
+Create `src/Mcp/Security/McpHeaderAuthenticator.php`:
+
+```php
+headers->has('X-Profile-Id') || !$request->headers->has('X-Profile-Password')) {
+ return false;
+ }
+
+ return true;
+ }
+
+ public function authenticate(Request $request): Passport
+ {
+ $profileId = $request->headers->get('X-Profile-Id', '');
+ $password = $request->headers->get('X-Profile-Password', '');
+
+ // Rate limiting per IP — consume 1 token per attempt
+ $limiter = $this->mcpAuthLimiter->create($request->getClientIp() ?? 'unknown');
+ $limit = $limiter->consume(1);
+
+ if (!$limit->isAccepted()) {
+ $this->mcpLogger->warning('MCP auth rate limited', ['ip' => $request->getClientIp()]);
+ throw new CustomUserMessageAuthenticationException('Rate limited: too many authentication attempts.');
+ }
+
+ return new SelfValidatingPassport(
+ new UserBadge($profileId, function (string $id) use ($password, $limiter, $request): Profile {
+ $profile = $this->profiles->find($id);
+
+ if (!$profile || !$profile->isActive()) {
+ $this->mcpLogger->warning('MCP auth failed: profile not found', ['profileId' => $id]);
+ throw new CustomUserMessageAuthenticationException('Authentication failed: invalid credentials.');
+ }
+
+ if (!$this->passwordHasher->isPasswordValid($profile, $password)) {
+ $this->mcpLogger->warning('MCP auth failed: invalid password', ['profileId' => $id]);
+ throw new CustomUserMessageAuthenticationException('Authentication failed: invalid credentials.');
+ }
+
+ // Reset limiter on successful auth
+ $limiter->reset();
+
+ $this->mcpLogger->info('MCP auth success', [
+ 'profileId' => $id,
+ 'roles' => $profile->getRoles(),
+ 'ip' => $request->getClientIp(),
+ ]);
+
+ return $profile;
+ })
+ );
+ }
+
+ public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
+ {
+ return null;
+ }
+
+ public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
+ {
+ $statusCode = str_contains($exception->getMessageKey(), 'Rate limited')
+ ? Response::HTTP_TOO_MANY_REQUESTS
+ : Response::HTTP_UNAUTHORIZED;
+
+ return new JsonResponse(
+ ['message' => $exception->getMessageKey()],
+ $statusCode,
+ );
+ }
+}
+```
+
+- [ ] **Step 4: Add MCP firewall to security.yaml**
+
+Add the `mcp` firewall BEFORE the `api` firewall in `config/packages/security.yaml`:
+
+```yaml
+ firewalls:
+ dev:
+ pattern: ^/(_profiler|_wdt|assets|build)/
+ security: false
+
+ session_public:
+ pattern: ^/api/session/profiles?$
+ security: false
+
+ mcp:
+ pattern: ^/_mcp
+ stateless: true
+ custom_authenticators:
+ - App\Mcp\Security\McpHeaderAuthenticator
+
+ api:
+ pattern: ^/api
+ stateless: false
+ custom_authenticators:
+ - App\Security\SessionProfileAuthenticator
+```
+
+Also add access control for `/_mcp` before the `/api` rules:
+
+```yaml
+ access_control:
+ - { path: ^/api/session/profile$, roles: PUBLIC_ACCESS }
+ - { path: ^/api/session/profiles, roles: PUBLIC_ACCESS, methods: [GET] }
+ - { path: ^/api/admin, roles: ROLE_ADMIN }
+ - { path: ^/api/docs, roles: PUBLIC_ACCESS }
+ - { path: ^/api/health$, roles: PUBLIC_ACCESS }
+ - { path: ^/_mcp, roles: ROLE_USER }
+ - { path: ^/docs, roles: PUBLIC_ACCESS }
+ - { path: ^/contexts, roles: PUBLIC_ACCESS }
+ - { path: ^/\.well-known, roles: PUBLIC_ACCESS }
+ - { path: ^/api, roles: ROLE_VIEWER }
+```
+
+- [ ] **Step 5: Run test to verify it passes**
+
+```bash
+make test FILES=tests/Mcp/Security/McpHeaderAuthenticatorTest.php
+```
+
+Expected: 3 tests pass.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/Mcp/Security/McpHeaderAuthenticator.php tests/Mcp/Security/McpHeaderAuthenticatorTest.php config/packages/security.yaml
+git commit -m "feat(mcp) : add McpHeaderAuthenticator with rate limiting"
+```
+
+---
+
+### Task 3: Stdio auth — EventSubscriber for console transport
+
+The `McpHeaderAuthenticator` only works for HTTP requests. For stdio transport (`bin/console mcp:server`), we need to read credentials from env vars and inject a security token manually.
+
+**Files:**
+- Create: `src/Mcp/Security/McpStdioAuthSubscriber.php`
+
+- [ ] **Step 1: Create the subscriber**
+
+Create `src/Mcp/Security/McpStdioAuthSubscriber.php`:
+
+```php
+getCommand();
+ if (!$command || !str_starts_with($command->getName() ?? '', 'mcp:')) {
+ return;
+ }
+
+ $profileId = $_ENV['MCP_PROFILE_ID'] ?? '';
+ $password = $_ENV['MCP_PROFILE_PASSWORD'] ?? '';
+
+ if ($profileId === '' || $password === '') {
+ $this->mcpLogger->error('MCP stdio: missing MCP_PROFILE_ID or MCP_PROFILE_PASSWORD env vars');
+ $event->disableCommand();
+ $event->getOutput()->writeln('MCP auth: MCP_PROFILE_ID and MCP_PROFILE_PASSWORD env vars required');
+ return;
+ }
+
+ $profile = $this->profiles->find($profileId);
+
+ if (!$profile || !$profile->isActive()) {
+ $this->mcpLogger->error('MCP stdio: profile not found or inactive', ['profileId' => $profileId]);
+ $event->disableCommand();
+ $event->getOutput()->writeln('MCP auth: invalid profile');
+ return;
+ }
+
+ if (!$this->passwordHasher->isPasswordValid($profile, $password)) {
+ $this->mcpLogger->error('MCP stdio: invalid password', ['profileId' => $profileId]);
+ $event->disableCommand();
+ $event->getOutput()->writeln('MCP auth: invalid password');
+ return;
+ }
+
+ // Inject a synthetic security token so $security->getUser() works in tools
+ $token = new UsernamePasswordToken($profile, 'mcp', $profile->getRoles());
+ $this->tokenStorage->setToken($token);
+
+ $this->mcpLogger->info('MCP stdio auth success', [
+ 'profileId' => $profileId,
+ 'roles' => $profile->getRoles(),
+ ]);
+ }
+}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add src/Mcp/Security/McpStdioAuthSubscriber.php
+git commit -m "feat(mcp) : add stdio auth subscriber for console transport"
+```
+
+---
+
+### Task 4: PoC Tool — get_dashboard_stats
+
+**Files:**
+- Create: `src/Mcp/Tool/DashboardStatsTool.php`
+- Create: `tests/Mcp/Tool/DashboardStatsToolTest.php`
+
+- [ ] **Step 1: Write the test**
+
+Create `tests/Mcp/Tool/DashboardStatsToolTest.php`:
+
+```php
+createSite();
+ $this->createMachine(site: $site);
+ $this->createMachine(site: $site);
+ $constructeur = $this->createConstructeur();
+ $modelType = $this->createModelType(category: 'piece');
+ $this->createPiece(modelType: $modelType, constructeur: $constructeur);
+
+ $profile = $this->createProfile(role: 'ROLE_VIEWER', password: 'test123');
+ $client = $this->createUnauthenticatedClient();
+
+ $response = $client->request('POST', '/_mcp', [
+ 'headers' => [
+ 'Content-Type' => 'application/json',
+ 'X-Profile-Id' => $profile->getId(),
+ 'X-Profile-Password' => 'test123',
+ ],
+ 'body' => json_encode([
+ 'jsonrpc' => '2.0',
+ 'method' => 'tools/call',
+ 'params' => [
+ 'name' => 'get_dashboard_stats',
+ 'arguments' => new \stdClass(),
+ ],
+ 'id' => 2,
+ ]),
+ ]);
+
+ $this->assertResponseIsSuccessful();
+ $data = $response->toArray();
+ $this->assertArrayHasKey('result', $data);
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+```bash
+make test FILES=tests/Mcp/Tool/DashboardStatsToolTest.php
+```
+
+Expected: FAIL — tool `get_dashboard_stats` not found.
+
+- [ ] **Step 3: Create the tool**
+
+Create `src/Mcp/Tool/DashboardStatsTool.php`:
+
+```php
+ 'object',
+ 'properties' => new \stdClass(),
+ ]
+ )]
+ 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)),
+ ];
+ }
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+```bash
+make test FILES=tests/Mcp/Tool/DashboardStatsToolTest.php
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Verify stdio transport works**
+
+```bash
+echo '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}},"id":1}' | docker exec -i -e MCP_PROFILE_ID= -e MCP_PROFILE_PASSWORD= php-inventory-apache php bin/console mcp:server
+```
+
+Expected: JSON-RPC response with server capabilities.
+
+> **Note:** This step validates the PoC. If stdio does not work, investigate the MCP bundle's console command API and adapt. Update this plan if the command name or args differ.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/Mcp/Tool/DashboardStatsTool.php tests/Mcp/Tool/DashboardStatsToolTest.php
+git commit -m "feat(mcp) : add get_dashboard_stats PoC tool"
+```
+
+---
+
+### Task 5: Base helper trait for MCP tools
+
+Tools share common patterns: role checking, error formatting, pagination. Extract a reusable trait.
+
+**Files:**
+- Create: `src/Mcp/Tool/McpToolHelper.php`
+
+- [ ] **Step 1: Create the helper trait**
+
+Create `src/Mcp/Tool/McpToolHelper.php`:
+
+```php
+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))];
+ }
+
+ /**
+ * Throws a RuntimeException that the MCP bundle catches and converts to
+ * an isError: true response. The message is prefixed with the category
+ * so LLMs can parse error types.
+ *
+ * Verify during PoC (Task 4) that the bundle converts RuntimeException
+ * to proper MCP error responses. If not, adapt to the bundle's error API.
+ */
+ private function mcpError(string $category, string $message): never
+ {
+ throw new \RuntimeException("{$category}: {$message}");
+ }
+
+ 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];
+ }
+
+ 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 / $limit),
+ ]);
+ }
+}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add src/Mcp/Tool/McpToolHelper.php
+git commit -m "feat(mcp) : add McpToolHelper trait for shared tool utilities"
+```
+
+---
+
+## Chunk 2: CRUD Tools — Sites, Constructeurs, Products
+
+### Task 6: Sites CRUD tools
+
+**Files:**
+- Create: `src/Mcp/Tool/Site/ListSitesTool.php`
+- Create: `src/Mcp/Tool/Site/GetSiteTool.php`
+- Create: `src/Mcp/Tool/Site/CreateSiteTool.php`
+- Create: `src/Mcp/Tool/Site/UpdateSiteTool.php`
+- Create: `src/Mcp/Tool/Site/DeleteSiteTool.php`
+- Create: `tests/Mcp/Tool/Site/SitesCrudToolTest.php`
+
+- [ ] **Step 1: Write tests for all 5 site CRUD tools**
+
+Create `tests/Mcp/Tool/Site/SitesCrudToolTest.php` — test each tool: list returns paginated sites, get returns a single site, create makes a new site (GESTIONNAIRE), update modifies fields, delete removes. Test that VIEWER can list/get but not create/update/delete.
+
+Pattern for each test:
+1. Create fixtures with `$this->createSite()` / `$this->createProfile()`
+2. Call `POST /_mcp` with `tools/call` JSON-RPC, `name: "list_sites"`, `arguments: {page: 1, limit: 10}`
+3. Assert response contains expected data
+
+- [ ] **Step 2: Run tests — expect failure**
+
+```bash
+make test FILES=tests/Mcp/Tool/Site/SitesCrudToolTest.php
+```
+
+- [ ] **Step 3: Implement ListSitesTool**
+
+Create `src/Mcp/Tool/Site/ListSitesTool.php`:
+
+```php
+ 'object',
+ 'properties' => [
+ 'page' => ['type' => 'integer', 'description' => 'Page number (1-indexed)', 'default' => 1],
+ 'limit' => ['type' => 'integer', 'description' => 'Items per page (max 100)', 'default' => 30],
+ 'search' => ['type' => 'string', 'description' => 'Search by site name'],
+ ],
+ ]
+ )]
+ public function __invoke(int $page = 1, int $limit = 30, ?string $search = null): array
+ {
+ $p = $this->paginationParams($page, $limit);
+ $qb = $this->sites->createQueryBuilder('s')->orderBy('s.name', 'ASC');
+
+ if ($search) {
+ $qb->andWhere('LOWER(s.name) LIKE LOWER(:search)')
+ ->setParameter('search', "%{$search}%");
+ }
+
+ $total = (int) (clone $qb)->select('COUNT(s.id)')->getQuery()->getSingleScalarResult();
+
+ $items = $qb->setFirstResult($p['offset'])
+ ->setMaxResults($p['limit'])
+ ->getQuery()
+ ->getArrayResult();
+
+ return $this->paginatedResponse($items, $total, $p['page'], $p['limit']);
+ }
+}
+```
+
+- [ ] **Step 4: Implement GetSiteTool, CreateSiteTool, UpdateSiteTool, DeleteSiteTool**
+
+Follow the same pattern as ListSitesTool. Each tool:
+- Has `#[McpTool]` attribute with name, description, inputSchema
+- Uses `McpToolHelper` trait
+- Get: takes `siteId: string`, returns site data or error if not found
+- Create: takes `name, address?, phone?, email?, contactName?`, calls `$this->requireRole($security, 'ROLE_GESTIONNAIRE')`, persists via EntityManager
+- Update: takes `siteId` + optional fields, merges changes
+- Delete: takes `siteId`, removes entity
+
+- [ ] **Step 5: Run tests — expect pass**
+
+```bash
+make test FILES=tests/Mcp/Tool/Site/SitesCrudToolTest.php
+```
+
+- [ ] **Step 6: Run php-cs-fixer**
+
+```bash
+make php-cs-fixer-allow-risky
+```
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add src/Mcp/Tool/Site/ tests/Mcp/Tool/Site/
+git commit -m "feat(mcp) : add Sites CRUD tools (list, get, create, update, delete)"
+```
+
+---
+
+### Task 7: Constructeurs CRUD tools
+
+**Files:**
+- Create: `src/Mcp/Tool/Constructeur/ListConstructeursTool.php`
+- Create: `src/Mcp/Tool/Constructeur/GetConstructeurTool.php`
+- Create: `src/Mcp/Tool/Constructeur/CreateConstructeurTool.php`
+- Create: `src/Mcp/Tool/Constructeur/UpdateConstructeurTool.php`
+- Create: `src/Mcp/Tool/Constructeur/DeleteConstructeurTool.php`
+- Create: `tests/Mcp/Tool/Constructeur/ConstructeursCrudToolTest.php`
+
+- [ ] **Step 1: Write tests**
+- [ ] **Step 2: Run tests — expect failure**
+- [ ] **Step 3: Implement all 5 tools** (same pattern as Sites)
+- [ ] **Step 4: Run tests — expect pass**
+- [ ] **Step 5: Run php-cs-fixer**
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/Mcp/Tool/Constructeur/ tests/Mcp/Tool/Constructeur/
+git commit -m "feat(mcp) : add Constructeurs CRUD tools"
+```
+
+---
+
+### Task 8: Products CRUD tools
+
+**Files:**
+- Create: `src/Mcp/Tool/Product/ListProductsTool.php`
+- Create: `src/Mcp/Tool/Product/GetProductTool.php`
+- Create: `src/Mcp/Tool/Product/CreateProductTool.php`
+- Create: `src/Mcp/Tool/Product/UpdateProductTool.php`
+- Create: `src/Mcp/Tool/Product/DeleteProductTool.php`
+- Create: `tests/Mcp/Tool/Product/ProductsCrudToolTest.php`
+
+- [ ] **Step 1: Write tests**
+
+Important: `prix` (price) must be sent as **string**, not number (see project memory `feedback_prix_type.md`).
+
+- [ ] **Step 2: Run tests — expect failure**
+- [ ] **Step 3: Implement all 5 tools**
+
+`CreateProductTool` takes: `name: string`, `reference?: string`, `modelTypeId?: string`, `prix?: string` (STRING not number), `constructeurIds?: string[]`.
+
+- [ ] **Step 4: Run tests — expect pass**
+- [ ] **Step 5: Run php-cs-fixer**
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/Mcp/Tool/Product/ tests/Mcp/Tool/Product/
+git commit -m "feat(mcp) : add Products CRUD tools"
+```
+
+---
+
+## Chunk 3: CRUD Tools — Pieces, Composants, Machines
+
+### Task 9: Pieces CRUD tools
+
+**Files:**
+- Create: `src/Mcp/Tool/Piece/ListPiecesTool.php`
+- Create: `src/Mcp/Tool/Piece/GetPieceTool.php`
+- Create: `src/Mcp/Tool/Piece/CreatePieceTool.php`
+- Create: `src/Mcp/Tool/Piece/UpdatePieceTool.php`
+- Create: `src/Mcp/Tool/Piece/DeletePieceTool.php`
+- Create: `tests/Mcp/Tool/Piece/PiecesCrudToolTest.php`
+
+- [ ] **Step 1: Write tests**
+
+`CreatePieceTool` test must verify that after creation, the response includes auto-generated product slots from the ModelType's skeleton requirements.
+
+- [ ] **Step 2: Run tests — expect failure**
+- [ ] **Step 3: Implement all 5 tools**
+
+`CreatePieceTool`: after persisting the Piece, read back its `PieceProductSlot` entities (auto-created by Doctrine lifecycle) and include them in the response.
+
+`GetPieceTool`: include product slots in the response.
+
+- [ ] **Step 4: Run tests — expect pass**
+- [ ] **Step 5: Run php-cs-fixer**
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/Mcp/Tool/Piece/ tests/Mcp/Tool/Piece/
+git commit -m "feat(mcp) : add Pieces CRUD tools with slot auto-generation"
+```
+
+---
+
+### Task 10: Composants CRUD tools
+
+**Files:**
+- Create: `src/Mcp/Tool/Composant/ListComposantsTool.php`
+- Create: `src/Mcp/Tool/Composant/GetComposantTool.php`
+- Create: `src/Mcp/Tool/Composant/CreateComposantTool.php`
+- Create: `src/Mcp/Tool/Composant/UpdateComposantTool.php`
+- Create: `src/Mcp/Tool/Composant/DeleteComposantTool.php`
+- Create: `tests/Mcp/Tool/Composant/ComposantsCrudToolTest.php`
+
+- [ ] **Step 1: Write tests**
+
+`CreateComposantTool` test must verify that the response includes auto-generated slots: `ComposantPieceSlot`, `ComposantProductSlot`, `ComposantSubcomponentSlot`.
+
+- [ ] **Step 2: Run tests — expect failure**
+- [ ] **Step 3: Implement all 5 tools**
+
+`CreateComposantTool`: after persisting, read back all 3 types of slots and include in response.
+
+`GetComposantTool`: include all slots with their state (selectedPieceId/selectedProductId/selectedComposantId + requirement name).
+
+- [ ] **Step 4: Run tests — expect pass**
+- [ ] **Step 5: Run php-cs-fixer**
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/Mcp/Tool/Composant/ tests/Mcp/Tool/Composant/
+git commit -m "feat(mcp) : add Composants CRUD tools with slot auto-generation"
+```
+
+---
+
+### Task 11: Machines CRUD tools
+
+**Files:**
+- Create: `src/Mcp/Tool/Machine/ListMachinesTool.php`
+- Create: `src/Mcp/Tool/Machine/GetMachineTool.php`
+- Create: `src/Mcp/Tool/Machine/CreateMachineTool.php`
+- Create: `src/Mcp/Tool/Machine/UpdateMachineTool.php`
+- Create: `src/Mcp/Tool/Machine/DeleteMachineTool.php`
+- Create: `tests/Mcp/Tool/Machine/MachinesCrudToolTest.php`
+
+- [ ] **Step 1: Write tests**
+- [ ] **Step 2: Run tests — expect failure**
+- [ ] **Step 3: Implement all 5 tools**
+
+`CreateMachineTool`: takes `name, reference?, siteId, constructeurIds?`. Returns machine data.
+
+- [ ] **Step 4: Run tests — expect pass**
+- [ ] **Step 5: Run php-cs-fixer**
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/Mcp/Tool/Machine/ tests/Mcp/Tool/Machine/
+git commit -m "feat(mcp) : add Machines CRUD tools"
+```
+
+---
+
+## Chunk 4: Specialized Tools — Slots, Links, Structure, Clone
+
+### Task 12: Slot tools (list_slots, update_slots)
+
+**Files:**
+- Create: `src/Mcp/Tool/Slot/ListSlotsTool.php`
+- Create: `src/Mcp/Tool/Slot/UpdateSlotsTool.php`
+- Create: `tests/Mcp/Tool/Slot/SlotsToolTest.php`
+
+- [ ] **Step 1: Write tests**
+
+Test `list_slots` for both composant and piece entity types. Test `update_slots` by selecting a piece into a composant's piece slot.
+
+- [ ] **Step 2: Run tests — expect failure**
+- [ ] **Step 3: Implement ListSlotsTool**
+
+Accepts `entityType: "composant"|"piece"`, `entityId: string`. Queries the appropriate slot repositories (`ComposantPieceSlotRepository`, `ComposantProductSlotRepository`, `ComposantSubcomponentSlotRepository` for composant; `PieceProductSlotRepository` for piece). Returns slots with requirement name, selected entity, position.
+
+- [ ] **Step 4: Implement UpdateSlotsTool**
+
+Accepts `slots: [{slotId, slotType: "piece"|"product"|"subcomponent", selectedPieceId?|selectedProductId?|selectedComposantId?}]`. Uses the existing slot controllers' logic: find slot, set selected entity, flush.
+
+- [ ] **Step 5: Run tests — expect pass**
+- [ ] **Step 6: Run php-cs-fixer and commit**
+
+```bash
+git add src/Mcp/Tool/Slot/ tests/Mcp/Tool/Slot/
+git commit -m "feat(mcp) : add list_slots and update_slots tools"
+```
+
+---
+
+### Task 13: Machine links tools
+
+**Files:**
+- Create: `src/Mcp/Tool/Machine/ListMachineLinksTool.php`
+- Create: `src/Mcp/Tool/Machine/AddMachineLinksTool.php`
+- Create: `src/Mcp/Tool/Machine/UpdateMachineLinkTool.php`
+- Create: `src/Mcp/Tool/Machine/RemoveMachineLinkTool.php`
+- Create: `tests/Mcp/Tool/Machine/MachineLinksToolTest.php`
+
+- [ ] **Step 1: Write tests**
+- [ ] **Step 2: Run tests — expect failure**
+- [ ] **Step 3: Implement all 4 tools**
+
+`AddMachineLinksTool`: accepts `machineId`, `links: [{type: "composant"|"piece"|"product", entityId, quantity?, parentLinkId?}]`. Creates `MachineComponentLink`, `MachinePieceLink`, or `MachineProductLink` accordingly.
+
+`ListMachineLinksTool`: returns all links for a machine, grouped by type.
+
+- [ ] **Step 4: Run tests — expect pass**
+- [ ] **Step 5: Run php-cs-fixer and commit**
+
+```bash
+git add src/Mcp/Tool/Machine/ListMachineLinksTool.php src/Mcp/Tool/Machine/AddMachineLinksTool.php src/Mcp/Tool/Machine/UpdateMachineLinkTool.php src/Mcp/Tool/Machine/RemoveMachineLinkTool.php tests/Mcp/Tool/Machine/MachineLinksToolTest.php
+git commit -m "feat(mcp) : add Machine links tools (list, add, update, remove)"
+```
+
+---
+
+### Task 14: Machine structure and clone tools
+
+**Files:**
+- Create: `src/Mcp/Tool/Machine/MachineStructureTool.php`
+- Create: `src/Mcp/Tool/Machine/CloneMachineTool.php`
+- Create: `tests/Mcp/Tool/Machine/MachineStructureToolTest.php`
+
+- [ ] **Step 1: Write tests**
+
+Test `get_machine_structure` returns the full hierarchy. Test `clone_machine` creates a copy with all links.
+
+- [ ] **Step 2: Run tests — expect failure**
+- [ ] **Step 3: Implement MachineStructureTool**
+
+Reuse the logic from `MachineStructureController::getStructure()` — query machine with all links, composants, pieces, products, custom fields, and return as nested JSON.
+
+- [ ] **Step 4: Implement CloneMachineTool**
+
+Reuse the logic from `MachineStructureController::cloneMachine()`. Accepts `machineId, name, siteId, reference?`. Returns the new machine's structure.
+
+- [ ] **Step 5: Run tests — expect pass**
+- [ ] **Step 6: Run php-cs-fixer and commit**
+
+```bash
+git add src/Mcp/Tool/Machine/MachineStructureTool.php src/Mcp/Tool/Machine/CloneMachineTool.php tests/Mcp/Tool/Machine/MachineStructureToolTest.php
+git commit -m "feat(mcp) : add get_machine_structure and clone_machine tools"
+```
+
+---
+
+## Chunk 5: Business Tools — Search, History, Comments, Custom Fields, ModelTypes
+
+### Task 15: search_inventory tool
+
+**Files:**
+- Create: `src/Mcp/Tool/SearchInventoryTool.php`
+- Create: `tests/Mcp/Tool/SearchInventoryToolTest.php`
+
+- [ ] **Step 1: Write tests**
+
+Test searching across multiple entity types (machines, pieces, composants, products, sites, constructeurs) by name/reference.
+
+- [ ] **Step 2: Run tests — expect failure**
+- [ ] **Step 3: Implement SearchInventoryTool**
+
+Accepts `query: string`, `types?: string[]` (defaults to all), `limit?: int` (default 20). Runs LIKE queries across entity tables, merges and sorts results by relevance. Returns `[{type, id, name, reference, ...}]`.
+
+- [ ] **Step 4: Run tests — expect pass**
+- [ ] **Step 5: Run php-cs-fixer and commit**
+
+```bash
+git add src/Mcp/Tool/SearchInventoryTool.php tests/Mcp/Tool/SearchInventoryToolTest.php
+git commit -m "feat(mcp) : add search_inventory global search tool"
+```
+
+---
+
+### Task 16: History and activity log tools
+
+**Files:**
+- Create: `src/Mcp/Tool/EntityHistoryTool.php`
+- Create: `src/Mcp/Tool/ActivityLogTool.php`
+- Create: `tests/Mcp/Tool/HistoryToolsTest.php`
+
+- [ ] **Step 1: Write tests**
+- [ ] **Step 2: Run tests — expect failure**
+- [ ] **Step 3: Implement EntityHistoryTool**
+
+Reuse logic from `EntityHistoryController`. Accepts `entityType: string`, `entityId: string`. Queries `AuditLog` by entity type/id.
+
+- [ ] **Step 4: Implement ActivityLogTool**
+
+Reuse logic from `ActivityLogController`. Accepts `page?, limit?, entityType?, action?`.
+
+- [ ] **Step 5: Run tests — expect pass**
+- [ ] **Step 6: Run php-cs-fixer and commit**
+
+```bash
+git add src/Mcp/Tool/EntityHistoryTool.php src/Mcp/Tool/ActivityLogTool.php tests/Mcp/Tool/HistoryToolsTest.php
+git commit -m "feat(mcp) : add entity history and activity log tools"
+```
+
+---
+
+### Task 17: Comment tools
+
+**Files:**
+- Create: `src/Mcp/Tool/Comment/ListCommentsTool.php`
+- Create: `src/Mcp/Tool/Comment/CreateCommentTool.php`
+- Create: `src/Mcp/Tool/Comment/ResolveCommentTool.php`
+- Create: `src/Mcp/Tool/Comment/UnresolvedCountTool.php`
+- Create: `tests/Mcp/Tool/Comment/CommentsToolTest.php`
+
+- [ ] **Step 1: Write tests**
+- [ ] **Step 2: Run tests — expect failure**
+- [ ] **Step 3: Implement all 4 comment tools**
+
+Reuse logic from `CommentController`. `CreateCommentTool` requires ROLE_VIEWER. `ResolveCommentTool` requires ROLE_GESTIONNAIRE.
+
+- [ ] **Step 4: Run tests — expect pass**
+- [ ] **Step 5: Run php-cs-fixer and commit**
+
+```bash
+git add src/Mcp/Tool/Comment/ tests/Mcp/Tool/Comment/
+git commit -m "feat(mcp) : add comment tools (list, create, resolve, unresolved count)"
+```
+
+---
+
+### Task 18: Custom field tools
+
+**Files:**
+- Create: `src/Mcp/Tool/CustomField/ListCustomFieldValuesTool.php`
+- Create: `src/Mcp/Tool/CustomField/UpsertCustomFieldValuesTool.php`
+- Create: `src/Mcp/Tool/CustomField/DeleteCustomFieldValueTool.php`
+- Create: `tests/Mcp/Tool/CustomField/CustomFieldToolsTest.php`
+
+- [ ] **Step 1: Write tests**
+- [ ] **Step 2: Run tests — expect failure**
+- [ ] **Step 3: Implement all 3 tools**
+
+Reuse logic from `CustomFieldValueController`. `UpsertCustomFieldValuesTool` accepts `entityType, entityId, fields: [{customFieldId OR (customFieldName + customFieldType), value}]`.
+
+- [ ] **Step 4: Run tests — expect pass**
+- [ ] **Step 5: Run php-cs-fixer and commit**
+
+```bash
+git add src/Mcp/Tool/CustomField/ tests/Mcp/Tool/CustomField/
+git commit -m "feat(mcp) : add custom field value tools (list, upsert, delete)"
+```
+
+---
+
+### Task 19: Document tools
+
+**Files:**
+- Create: `src/Mcp/Tool/Document/ListDocumentsTool.php`
+- Create: `src/Mcp/Tool/Document/DeleteDocumentTool.php`
+- Create: `tests/Mcp/Tool/Document/DocumentToolsTest.php`
+
+- [ ] **Step 1: Write tests**
+- [ ] **Step 2: Run tests — expect failure**
+- [ ] **Step 3: Implement both tools**
+
+Reuse logic from `DocumentQueryController` for list and `DocumentRepository` for delete. No upload tool (documented limitation).
+
+- [ ] **Step 4: Run tests — expect pass**
+- [ ] **Step 5: Run php-cs-fixer and commit**
+
+```bash
+git add src/Mcp/Tool/Document/ tests/Mcp/Tool/Document/
+git commit -m "feat(mcp) : add document tools (list, delete)"
+```
+
+---
+
+### Task 20: ModelType tools
+
+**Files:**
+- Create: `src/Mcp/Tool/ModelType/ListModelTypesTool.php`
+- Create: `src/Mcp/Tool/ModelType/GetModelTypeTool.php`
+- Create: `src/Mcp/Tool/ModelType/CreateModelTypeTool.php`
+- Create: `src/Mcp/Tool/ModelType/UpdateModelTypeTool.php`
+- Create: `src/Mcp/Tool/ModelType/DeleteModelTypeTool.php`
+- Create: `src/Mcp/Tool/ModelType/SyncModelTypeTool.php`
+- Create: `tests/Mcp/Tool/ModelType/ModelTypeToolsTest.php`
+
+- [ ] **Step 1: Write tests**
+
+`GetModelTypeTool` test must verify skeleton requirements and custom fields are included. `SyncModelTypeTool` test must verify preview and sync actions.
+
+- [ ] **Step 2: Run tests — expect failure**
+- [ ] **Step 3: Implement CRUD tools**
+
+`GetModelTypeTool` returns full detail including `SkeletonPieceRequirement`, `SkeletonProductRequirement`, `SkeletonSubcomponentRequirement` with their custom fields.
+
+- [ ] **Step 4: Implement SyncModelTypeTool**
+
+Reuse logic from `ModelTypeSyncController`. Accepts `modelTypeId, action: "preview"|"sync", structure?, confirmDeletions?, confirmTypeChanges?`.
+
+- [ ] **Step 5: Run tests — expect pass**
+- [ ] **Step 6: Run php-cs-fixer and commit**
+
+```bash
+git add src/Mcp/Tool/ModelType/ tests/Mcp/Tool/ModelType/
+git commit -m "feat(mcp) : add ModelType tools (CRUD + sync)"
+```
+
+---
+
+## Chunk 6: Resources, Documentation, Final Validation
+
+### Task 21: MCP Resources
+
+**Files:**
+- Create: `src/Mcp/Resource/SchemaResource.php`
+- Create: `src/Mcp/Resource/ModelTypesResource.php`
+- Create: `src/Mcp/Resource/RolesResource.php`
+- Create: `src/Mcp/Resource/StatsResource.php`
+
+- [ ] **Step 1: Implement SchemaResource**
+
+```php
+ [
+ 'fields' => ['id (string, CUID)', 'name (string)', 'reference (string, nullable)', 'siteId (string)', 'createdAt', 'updatedAt'],
+ 'relationships' => ['site (Site)', 'constructeurs (Constructeur[])', 'componentLinks (MachineComponentLink[])', 'pieceLinks (MachinePieceLink[])', 'productLinks (MachineProductLink[])', 'customFieldValues (CustomFieldValue[])', 'documents (Document[])'],
+ ],
+ 'Composant' => [
+ 'fields' => ['id (string, CUID)', 'name (string)', 'reference (string, nullable)', 'modelTypeId (string, nullable)', 'createdAt', 'updatedAt'],
+ 'relationships' => ['modelType (ModelType)', 'constructeurs (Constructeur[])', 'pieceSlots (ComposantPieceSlot[])', 'productSlots (ComposantProductSlot[])', 'subcomponentSlots (ComposantSubcomponentSlot[])', 'customFieldValues (CustomFieldValue[])', 'documents (Document[])'],
+ ],
+ 'Piece' => [
+ 'fields' => ['id (string, CUID)', 'name (string)', 'reference (string, nullable)', 'modelTypeId (string, nullable)', 'createdAt', 'updatedAt'],
+ 'relationships' => ['modelType (ModelType)', 'constructeurs (Constructeur[])', 'productSlots (PieceProductSlot[])', 'customFieldValues (CustomFieldValue[])', 'documents (Document[])'],
+ ],
+ 'Product' => [
+ 'fields' => ['id (string, CUID)', 'name (string)', 'reference (string, nullable)', 'prix (string, nullable)', 'modelTypeId (string, nullable)', 'createdAt', 'updatedAt'],
+ 'relationships' => ['modelType (ModelType)', 'constructeurs (Constructeur[])', 'customFieldValues (CustomFieldValue[])', 'documents (Document[])'],
+ ],
+ 'Site' => [
+ 'fields' => ['id (string, CUID)', 'name (string)', 'address (string, nullable)', 'phone (string, nullable)', 'email (string, nullable)', 'contactName (string, nullable)', 'createdAt', 'updatedAt'],
+ 'relationships' => ['machines (Machine[])', 'documents (Document[])'],
+ ],
+ 'Constructeur' => [
+ 'fields' => ['id (string, CUID)', 'name (string)', 'website (string, nullable)', 'phone (string, nullable)', 'email (string, nullable)', 'createdAt', 'updatedAt'],
+ 'relationships' => ['machines (Machine[])', 'composants (Composant[])', 'pieces (Piece[])', 'products (Product[])'],
+ ],
+ 'ModelType' => [
+ 'fields' => ['id (string, CUID)', 'name (string)', 'category (string: machine|composant|piece|product)', 'createdAt', 'updatedAt'],
+ 'relationships' => ['skeletonPieceRequirements[]', 'skeletonProductRequirements[]', 'skeletonSubcomponentRequirements[]', 'customFields (CustomField[])'],
+ ],
+ ];
+
+ return [new TextContent(text: json_encode($schema, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE))];
+ }
+}
+```
+
+- [ ] **Step 2: Implement RolesResource, StatsResource, ModelTypesResource**
+
+`RolesResource`: static content describing role hierarchy and tool permissions.
+`StatsResource`: same logic as `DashboardStatsTool` but as a resource.
+`ModelTypesResource`: resource template with `{category}` parameter showing model types and their skeleton requirements.
+
+- [ ] **Step 3: Run php-cs-fixer and commit**
+
+```bash
+git add src/Mcp/Resource/
+git commit -m "feat(mcp) : add MCP resources (schema, roles, stats, model types)"
+```
+
+---
+
+### Task 22: User documentation
+
+**Files:**
+- Create: `docs/mcp/README.md`
+
+- [ ] **Step 1: Write the documentation**
+
+Create `docs/mcp/README.md` with sections as defined in the spec (section 14):
+1. Introduction — what is MCP Inventory, which clients are supported
+2. Prerequisites — profile with sufficient role, tunnel access
+3. Client configuration — copy-paste examples for Claude Code (stdio via Docker), Claude Desktop (HTTP), ChatGPT Desktop (HTTP), Codex (HTTP)
+4. Tool catalogue — table of all ~55 tools with name, description, parameters, required role
+5. Guided workflows — step-by-step for creating machine, composant, piece, product
+6. Resources — URIs and content
+7. Roles & permissions — role hierarchy and tool mapping
+8. Error format — categories and examples
+9. Known limitations — document upload
+10. Troubleshooting — common errors
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add docs/mcp/README.md
+git commit -m "docs(mcp) : add user documentation for MCP server"
+```
+
+---
+
+### Task 23: Create .mcp.json for Claude Code
+
+**Files:**
+- Create: `.mcp.json`
+
+- [ ] **Step 1: Create .mcp.json**
+
+Create `.mcp.json` at project root:
+
+```json
+{
+ "mcpServers": {
+ "inventory": {
+ "command": "docker",
+ "args": [
+ "exec", "-i",
+ "-e", "MCP_PROFILE_ID=",
+ "-e", "MCP_PROFILE_PASSWORD=",
+ "php-inventory-apache",
+ "php", "bin/console", "mcp:server"
+ ]
+ }
+ }
+}
+```
+
+> **Note:** This file contains credentials placeholders. Add `.mcp.json` to `.gitignore` if you don't want it committed, or use a `.mcp.json.example` template.
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add .mcp.json
+git commit -m "feat(mcp) : add .mcp.json for Claude Code stdio transport"
+```
+
+---
+
+### Task 24: Update CLAUDE.md with MCP documentation
+
+**Files:**
+- Modify: `CLAUDE.md`
+
+- [ ] **Step 1: Add MCP section to CLAUDE.md**
+
+Add a new section after "## Architecture Backend" covering:
+- New directory `src/Mcp/` — Tools, Resources, Security
+- MCP endpoint `/_mcp` with dedicated firewall
+- Console command `mcp:server` for stdio transport
+- Tool naming convention: `verb_entity` (e.g., `list_machines`, `create_composant`)
+- `#[McpTool]` attribute pattern with `inputSchema`
+- Return type: `array{TextContent}` with JSON-encoded content
+- Auth: `McpHeaderAuthenticator` for HTTP, `McpStdioAuthSubscriber` for stdio
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add CLAUDE.md
+git commit -m "docs(mcp) : add MCP architecture section to CLAUDE.md"
+```
+
+---
+
+### Task 25: Full test suite run and final validation
+
+- [ ] **Step 1: Run all MCP tests**
+
+```bash
+make test FILES=tests/Mcp/
+```
+
+Expected: all tests pass.
+
+- [ ] **Step 2: Run full project test suite**
+
+```bash
+make test
+```
+
+Expected: no regressions — existing tests still pass.
+
+- [ ] **Step 3: Run php-cs-fixer on all MCP files**
+
+```bash
+make php-cs-fixer-allow-risky
+```
+
+- [ ] **Step 4: Test stdio transport end-to-end**
+
+```bash
+echo '{"jsonrpc":"2.0","method":"tools/list","params":{},"id":1}' | docker exec -i -e MCP_PROFILE_ID= -e MCP_PROFILE_PASSWORD= php-inventory-apache php bin/console mcp:server
+```
+
+Expected: JSON response listing all ~55 tools.
+
+- [ ] **Step 5: Test HTTP transport end-to-end**
+
+```bash
+curl -X POST http://localhost:8081/_mcp \
+ -H "Content-Type: application/json" \
+ -H "X-Profile-Id: " \
+ -H "X-Profile-Password: " \
+ -d '{"jsonrpc":"2.0","method":"tools/list","params":{},"id":1}'
+```
+
+Expected: JSON response listing all tools.
+
+- [ ] **Step 6: Final commit (if any remaining unstaged files)**
+
+```bash
+git status
+# Only add specific files if there are remaining changes
+git commit -m "feat(mcp) : finalize MCP server implementation"
+```
diff --git a/docs/superpowers/specs/2026-03-16-mcp-server-design.md b/docs/superpowers/specs/2026-03-16-mcp-server-design.md
new file mode 100644
index 0000000..35bfc18
--- /dev/null
+++ b/docs/superpowers/specs/2026-03-16-mcp-server-design.md
@@ -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=",
+ "-e", "MCP_PROFILE_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": "",
+ "X-Profile-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.
diff --git a/src/Mcp/Resource/RolesResource.php b/src/Mcp/Resource/RolesResource.php
new file mode 100644
index 0000000..95a2874
--- /dev/null
+++ b/src/Mcp/Resource/RolesResource.php
@@ -0,0 +1,35 @@
+ [
+ '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))];
+ }
+}
diff --git a/src/Mcp/Resource/SchemaResource.php b/src/Mcp/Resource/SchemaResource.php
new file mode 100644
index 0000000..3842c77
--- /dev/null
+++ b/src/Mcp/Resource/SchemaResource.php
@@ -0,0 +1,53 @@
+ [
+ '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))];
+ }
+}
diff --git a/src/Mcp/Resource/StatsResource.php b/src/Mcp/Resource/StatsResource.php
new file mode 100644
index 0000000..c934d73
--- /dev/null
+++ b/src/Mcp/Resource/StatsResource.php
@@ -0,0 +1,48 @@
+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))];
+ }
+}