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

1473 lines
48 KiB
Markdown

# 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"
```