- 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>
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:
- Create fixtures with
$this->createSite()/$this->createProfile() - Call
POST /_mcpwithtools/callJSON-RPC,name: "list_sites",arguments: {page: 1, limit: 10} - 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
McpToolHelpertrait -
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"
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
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
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):
- Introduction — what is MCP Inventory, which clients are supported
- Prerequisites — profile with sufficient role, tunnel access
- Client configuration — copy-paste examples for Claude Code (stdio via Docker), Claude Desktop (HTTP), ChatGPT Desktop (HTTP), Codex (HTTP)
- Tool catalogue — table of all ~55 tools with name, description, parameters, required role
- Guided workflows — step-by-step for creating machine, composant, piece, product
- Resources — URIs and content
- Roles & permissions — role hierarchy and tool mapping
- Error format — categories and examples
- Known limitations — document upload
- 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.jsonto.gitignoreif you don't want it committed, or use a.mcp.json.exampletemplate.
- 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
/_mcpwith dedicated firewall -
Console command
mcp:serverfor stdio transport -
Tool naming convention:
verb_entity(e.g.,list_machines,create_composant) -
#[McpTool]attribute pattern withinputSchema -
Return type:
array{TextContent}with JSON-encoded content -
Auth:
McpHeaderAuthenticatorfor HTTP,McpStdioAuthSubscriberfor 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"