# 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
<?php

declare(strict_types=1);

namespace App\Tests\Mcp\Security;

use App\Tests\AbstractApiTestCase;

class McpHeaderAuthenticatorTest extends AbstractApiTestCase
{
    public function testMcpEndpointRejectsWithoutCredentials(): void
    {
        $client = $this->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
<?php

declare(strict_types=1);

namespace App\Mcp\Security;

use App\Entity\Profile;
use App\Repository\ProfileRepository;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;

final class McpHeaderAuthenticator extends AbstractAuthenticator
{
    public function __construct(
        private readonly ProfileRepository $profiles,
        private readonly UserPasswordHasherInterface $passwordHasher,
        private readonly RateLimiterFactory $mcpAuthLimiter,
        private readonly LoggerInterface $mcpLogger,
    ) {}

    public function supports(Request $request): ?bool
    {
        // Return false explicitly when headers are missing — do not return null
        // (null means "I don't know", which would let requests through without auth)
        if (!$request->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
<?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\UsernamePasswordToken;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;

#[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 $mcpLogger,
    ) {}

    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->mcpLogger->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->mcpLogger->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->mcpLogger->error('MCP stdio: invalid password', ['profileId' => $profileId]);
            $event->disableCommand();
            $event->getOutput()->writeln('<error>MCP auth: invalid password</error>');
            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
<?php

declare(strict_types=1);

namespace App\Tests\Mcp\Tool;

use App\Tests\AbstractApiTestCase;

class DashboardStatsToolTest extends AbstractApiTestCase
{
    public function testGetDashboardStatsReturnsCounters(): void
    {
        $site = $this->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
<?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;

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,
    ) {}

    #[McpTool(
        name: 'get_dashboard_stats',
        description: 'Get global inventory statistics: count of machines, pieces, composants, products, sites, and unresolved comments.',
        inputSchema: [
            'type' => '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=<an-existing-profile-id> -e MCP_PROFILE_PASSWORD=<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
<?php

declare(strict_types=1);

namespace App\Mcp\Tool;

use Mcp\Schema\Content\TextContent;
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))];
    }

    /**
     * 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
<?php

declare(strict_types=1);

namespace App\Mcp\Tool\Site;

use App\Mcp\Tool\McpToolHelper;
use App\Repository\SiteRepository;
use Mcp\Capability\Attribute\McpTool;

class ListSitesTool
{
    use McpToolHelper;

    public function __construct(
        private readonly SiteRepository $sites,
    ) {}

    #[McpTool(
        name: 'list_sites',
        description: 'List all sites with pagination. Sites are industrial locations that contain machines.',
        inputSchema: [
            'type' => '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
<?php

declare(strict_types=1);

namespace App\Mcp\Resource;

use Mcp\Capability\Attribute\McpResource;
use Mcp\Schema\Content\TextContent;

class SchemaResource
{
    #[McpResource(
        uri: 'inventory://schema/entities',
        name: 'Entity Schema',
        description: 'Complete schema of all inventory entities with their fields, types, and relationships.',
        mimeType: 'application/json'
    )]
    public function getSchema(): array
    {
        $schema = [
            'Machine' => [
                '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=<votre-profile-id>",
        "-e", "MCP_PROFILE_PASSWORD=<votre-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=<id> -e MCP_PROFILE_PASSWORD=<pw> 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: <id>" \
  -H "X-Profile-Password: <pw>" \
  -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"
```
