Files
Inventory/docs/superpowers/plans/2026-03-16-mcp-server.md
Matthieu f965affc94 feat(mcp) : add MCP resources, documentation, and .mcp.json config
- 3 MCP resources: schema, roles, stats
- docs/mcp/README.md with full user guide (config, tools catalogue, workflows)
- .mcp.json for Claude Code stdio transport
- Design spec and implementation plan

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 15:49:00 +01:00

48 KiB

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

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

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:

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):

mcp:
    resource: .
    type: mcp
  • Step 4: Create rate limiter config

Create config/packages/rate_limiter.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:

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
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
docker exec -u www-data php-inventory-apache php bin/console debug:router | grep mcp

Expected: /_mcp route listed.

  • Step 8: Commit
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

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
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

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:

    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:

    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
make test FILES=tests/Mcp/Security/McpHeaderAuthenticatorTest.php

Expected: 3 tests pass.

  • Step 6: Commit
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

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
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

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
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

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
make test FILES=tests/Mcp/Tool/DashboardStatsToolTest.php

Expected: PASS.

  • Step 5: Verify stdio transport works
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
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

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
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
make test FILES=tests/Mcp/Tool/Site/SitesCrudToolTest.php
  • Step 3: Implement ListSitesTool

Create src/Mcp/Tool/Site/ListSitesTool.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

make test FILES=tests/Mcp/Tool/Site/SitesCrudToolTest.php
  • Step 6: Run php-cs-fixer
make php-cs-fixer-allow-risky
  • Step 7: Commit
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

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
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
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
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
git add src/Mcp/Tool/Machine/ tests/Mcp/Tool/Machine/
git commit -m "feat(mcp) : add Machines CRUD tools"

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
git add src/Mcp/Tool/Slot/ tests/Mcp/Tool/Slot/
git commit -m "feat(mcp) : add list_slots and update_slots 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
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
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
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
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
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
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
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
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

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
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
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:

{
  "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
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

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
make test FILES=tests/Mcp/

Expected: all tests pass.

  • Step 2: Run full project test suite
make test

Expected: no regressions — existing tests still pass.

  • Step 3: Run php-cs-fixer on all MCP files
make php-cs-fixer-allow-risky
  • Step 4: Test stdio transport end-to-end
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
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)
git status
# Only add specific files if there are remaining changes
git commit -m "feat(mcp) : finalize MCP server implementation"