From f965affc94985f1150d553343e70015cc345d299 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Mon, 16 Mar 2026 15:49:00 +0100 Subject: [PATCH] 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) --- .mcp.json | 14 + docs/mcp/README.md | 185 +++ .../plans/2026-03-16-mcp-server.md | 1472 +++++++++++++++++ .../specs/2026-03-16-mcp-server-design.md | 669 ++++++++ src/Mcp/Resource/RolesResource.php | 35 + src/Mcp/Resource/SchemaResource.php | 53 + src/Mcp/Resource/StatsResource.php | 48 + 7 files changed, 2476 insertions(+) create mode 100644 .mcp.json create mode 100644 docs/mcp/README.md create mode 100644 docs/superpowers/plans/2026-03-16-mcp-server.md create mode 100644 docs/superpowers/specs/2026-03-16-mcp-server-design.md create mode 100644 src/Mcp/Resource/RolesResource.php create mode 100644 src/Mcp/Resource/SchemaResource.php create mode 100644 src/Mcp/Resource/StatsResource.php diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..1b7c056 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,14 @@ +{ + "mcpServers": { + "inventory": { + "command": "docker", + "args": [ + "exec", "-i", + "-e", "MCP_PROFILE_ID=REPLACE_WITH_YOUR_PROFILE_ID", + "-e", "MCP_PROFILE_PASSWORD=REPLACE_WITH_YOUR_PASSWORD", + "php-inventory-apache", + "php", "bin/console", "mcp:server" + ] + } + } +} diff --git a/docs/mcp/README.md b/docs/mcp/README.md new file mode 100644 index 0000000..4aa7e72 --- /dev/null +++ b/docs/mcp/README.md @@ -0,0 +1,185 @@ +# MCP Server — Inventory + +Serveur MCP (Model Context Protocol) pour l'application Inventory. Permet aux assistants IA (Claude, ChatGPT, Codex) de consulter et gérer l'inventaire industriel. + +## Prérequis + +- Un profil actif avec rôle suffisant (ROLE_VIEWER pour lecture, ROLE_GESTIONNAIRE pour écriture) +- Accès au tunnel pour les clients distants (Claude Desktop, ChatGPT Desktop) +- Docker Compose démarré (`make start`) + +## Configuration par client + +### Claude Code (local, stdio) + +Le fichier `.mcp.json` à la racine du projet est déjà configuré. Remplacez les placeholders : + +```json +{ + "mcpServers": { + "inventory": { + "command": "docker", + "args": [ + "exec", "-i", + "-e", "MCP_PROFILE_ID=VOTRE_PROFILE_ID", + "-e", "MCP_PROFILE_PASSWORD=VOTRE_PASSWORD", + "php-inventory-apache", + "php", "bin/console", "mcp:server" + ] + } + } +} +``` + +### Claude Desktop (HTTP via tunnel) + +Dans `claude_desktop_config.json` : + +```json +{ + "mcpServers": { + "inventory": { + "url": "https://inventory.company-tunnel.com/_mcp", + "headers": { + "X-Profile-Id": "VOTRE_PROFILE_ID", + "X-Profile-Password": "VOTRE_PASSWORD" + } + } + } +} +``` + +### ChatGPT Desktop / Codex + +Meme principe HTTP avec l'URL du tunnel + headers d'auth. + +## Catalogue des Tools + +### Tools de haut niveau + +| Tool | Description | Role | +|------|-------------|------| +| `search_inventory` | Recherche globale (machines, pieces, composants, produits, sites, constructeurs) | VIEWER | +| `get_machine_structure` | Hierarchie complete d'une machine | VIEWER | +| `clone_machine` | Clone une machine avec toute sa structure | GESTIONNAIRE | +| `get_dashboard_stats` | Statistiques globales | VIEWER | +| `get_entity_history` | Historique d'audit d'une entite | VIEWER | +| `get_activity_log` | Journal d'activite global | VIEWER | + +### CRUD par entite + +Pour chaque entite (Machine, Composant, Piece, Produit, Site, Constructeur) : + +| Pattern | Exemple | Role | +|---------|---------|------| +| `list_{entite}s` | `list_machines` | VIEWER | +| `get_{entite}` | `get_machine` | VIEWER | +| `create_{entite}` | `create_machine` | GESTIONNAIRE | +| `update_{entite}` | `update_machine` | GESTIONNAIRE | +| `delete_{entite}` | `delete_machine` | GESTIONNAIRE | + +### Slots + +| Tool | Description | Role | +|------|-------------|------| +| `list_slots` | Lister les slots d'un composant ou piece | VIEWER | +| `update_slots` | Remplir/vider les slots | GESTIONNAIRE | + +### Machine Links + +| Tool | Description | Role | +|------|-------------|------| +| `list_machine_links` | Liens composant/piece/produit d'une machine | VIEWER | +| `add_machine_links` | Ajouter des liens | GESTIONNAIRE | +| `update_machine_link` | Modifier un lien | GESTIONNAIRE | +| `remove_machine_link` | Supprimer un lien | GESTIONNAIRE | + +### Commentaires + +| Tool | Description | Role | +|------|-------------|------| +| `list_comments` | Lister les commentaires d'une entite | VIEWER | +| `create_comment` | Creer un commentaire | VIEWER | +| `resolve_comment` | Resoudre un commentaire | GESTIONNAIRE | +| `get_unresolved_comments_count` | Nombre de commentaires non resolus | VIEWER | + +### Custom Fields + +| Tool | Description | Role | +|------|-------------|------| +| `list_custom_field_values` | Valeurs de champs perso d'une entite | VIEWER | +| `upsert_custom_field_values` | Creer/mettre a jour des valeurs | GESTIONNAIRE | +| `delete_custom_field_value` | Supprimer une valeur | GESTIONNAIRE | + +### Documents + +| Tool | Description | Role | +|------|-------------|------| +| `list_documents` | Lister les documents d'une entite | VIEWER | +| `delete_document` | Supprimer un document | GESTIONNAIRE | + +> **Limitation :** L'upload de documents n'est pas supporte via MCP (protocole JSON uniquement). Utilisez l'API REST `/api/documents` (POST multipart). + +### ModelTypes + +| Tool | Description | Role | +|------|-------------|------| +| `list_model_types` | Lister par categorie | VIEWER | +| `get_model_type` | Detail avec skeleton requirements | VIEWER | +| `create_model_type` | Creer | GESTIONNAIRE | +| `update_model_type` | Modifier | GESTIONNAIRE | +| `delete_model_type` | Supprimer | GESTIONNAIRE | +| `sync_model_type` | Preview/sync skeleton | GESTIONNAIRE | + +## Workflows guides + +### Creer un composant complet + +``` +1. list_model_types(category: "composant") -> choisir le type +2. get_model_type(modelTypeId: "...") -> voir le skeleton +3. create_composant(name, reference, modelTypeId) -> cree + slots auto +4. search_inventory(query: "Roulement", types: "piece") -> trouver pieces +5. update_slots(slots: [{slotId, selectedPieceId}]) -> remplir +6. upsert_custom_field_values(entityType: "composant", entityId, fields: [...]) +``` + +### Creer une machine complete (bottom-up) + +``` +1. Creer les produits necessaires +2. Creer les pieces (avec produits dans les slots) +3. Creer les composants (avec pieces dans les slots) +4. list_sites -> choisir le site +5. create_machine(name, siteId) +6. add_machine_links(machineId, links: [{type: "composant", entityId, quantity}]) +7. upsert_custom_field_values(entityType: "machine", machineId, fields: [...]) +``` + +## Resources MCP + +| URI | Description | +|-----|-------------| +| `inventory://schema/entities` | Schema de toutes les entites | +| `inventory://roles` | Hierarchie des roles et permissions | +| `inventory://stats` | Statistiques globales | + +## Roles & Permissions + +``` +ROLE_ADMIN > ROLE_GESTIONNAIRE > ROLE_VIEWER > ROLE_USER +``` + +- **VIEWER** : lecture, recherche, commentaires +- **GESTIONNAIRE** : ecriture (CRUD, slots, links, clone) +- **ADMIN** : gestion profils (via API REST uniquement) + +## Troubleshooting + +| Erreur | Cause | Solution | +|--------|-------|----------| +| `401 Unauthorized` | Credentials invalides | Verifier X-Profile-Id et X-Profile-Password | +| `Permission denied: ROLE_GESTIONNAIRE required` | Role insuffisant | Utiliser un profil avec le bon role | +| `Rate limited` | Trop de tentatives echouees | Attendre 1 minute | +| `Tool not found` | Tool non enregistre | Verifier que le cache est a jour (`cache:clear`) | +| `Error while executing tool` | Erreur interne | Verifier les logs et les parametres | diff --git a/docs/superpowers/plans/2026-03-16-mcp-server.md b/docs/superpowers/plans/2026-03-16-mcp-server.md new file mode 100644 index 0000000..8f75603 --- /dev/null +++ b/docs/superpowers/plans/2026-03-16-mcp-server.md @@ -0,0 +1,1472 @@ +# MCP Server Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a Model Context Protocol (MCP) server to the Inventory Symfony app, exposing ~55 tools for full CRUD + business operations on all entities, with dual transport (stdio + HTTP) and pass-through authentication. + +**Architecture:** `symfony/mcp-bundle` v0.6.0 integrated into the existing Symfony 8 app. A dedicated firewall (`^/_mcp`) with `McpHeaderAuthenticator` handles pass-through auth via `X-Profile-Id`/`X-Profile-Password` headers. Tools are PHP classes with `#[McpTool]` attributes that access Doctrine repositories directly. Resources expose schema metadata. + +**Tech Stack:** PHP 8.4, Symfony 8.0, `symfony/mcp-bundle` ^0.6, `symfony/rate-limiter`, PostgreSQL 16, Docker Compose + +**Spec:** `docs/superpowers/specs/2026-03-16-mcp-server-design.md` + +--- + +## Pre-requisite: Create feature branch + +```bash +git checkout -b feat/mcp-server develop +``` + +--- + +## Chunk 1: Foundation — Bundle Install, Auth, PoC Tool + +### Task 1: Install dependencies and configure the MCP bundle + +**Files:** +- Modify: `composer.json` +- Create: `config/packages/mcp.yaml` +- Modify: `config/routes.yaml` +- Create: `config/packages/rate_limiter.yaml` + +- [ ] **Step 1: Install symfony/mcp-bundle and symfony/rate-limiter** + +```bash +docker exec -u www-data php-inventory-apache composer require symfony/mcp-bundle symfony/rate-limiter +``` + +- [ ] **Step 2: Create MCP bundle config** + +Create `config/packages/mcp.yaml`: + +```yaml +mcp: + app: 'inventory' + version: '%env(file:resolve:VERSION)%' + description: 'Inventory MCP Server - Gestion inventaire industriel (machines, pièces, composants, produits)' + instructions: | + Serveur MCP pour gérer un inventaire industriel. + Entités principales : Machine, Composant, Pièce, Produit, Site, Constructeur. + Utilisez search_inventory pour chercher dans toutes les entités. + Utilisez get_model_type pour comprendre la structure attendue avant de créer un composant ou une pièce. + Consultez la resource inventory://schema/entities pour voir le schéma complet. + Authentification requise : envoyez X-Profile-Id et X-Profile-Password dans les headers HTTP. + client_transports: + stdio: true + http: true + http: + path: /_mcp + session: + store: file + directory: '%kernel.cache_dir%/mcp-sessions' + ttl: 3600 +``` + +- [ ] **Step 3: Add MCP route** + +Add to `config/routes.yaml` (after existing routes): + +```yaml +mcp: + resource: . + type: mcp +``` + +- [ ] **Step 4: Create rate limiter config** + +Create `config/packages/rate_limiter.yaml`: + +```yaml +framework: + rate_limiter: + mcp_auth: + policy: sliding_window + limit: 5 + interval: '1 minute' +``` + +- [ ] **Step 5: Add MCP logging channel** + +Create `config/packages/monolog.yaml` (if not exists) or add to it: + +```yaml +monolog: + channels: ['mcp'] + handlers: + mcp: + type: rotating_file + path: '%kernel.logs_dir%/mcp.log' + level: info + channels: ['mcp'] + max_files: 30 +``` + +- [ ] **Step 6: Verify Symfony cache clear succeeds** + +```bash +docker exec -u www-data php-inventory-apache php bin/console cache:clear +``` + +Expected: no errors, MCP bundle loaded. + +- [ ] **Step 7: Verify MCP routes are registered** + +```bash +docker exec -u www-data php-inventory-apache php bin/console debug:router | grep mcp +``` + +Expected: `/_mcp` route listed. + +- [ ] **Step 8: Commit** + +```bash +git add composer.json composer.lock symfony.lock config/packages/mcp.yaml config/packages/rate_limiter.yaml config/packages/monolog.yaml config/routes.yaml +git commit -m "feat(mcp) : install symfony/mcp-bundle and configure transports" +``` + +--- + +### Task 2: Create McpHeaderAuthenticator + +**Files:** +- Create: `src/Mcp/Security/McpHeaderAuthenticator.php` +- Modify: `config/packages/security.yaml` + +- [ ] **Step 1: Write the authenticator test** + +Create `tests/Mcp/Security/McpHeaderAuthenticatorTest.php`: + +```php +createUnauthenticatedClient(); + + $client->request('POST', '/_mcp', [ + 'headers' => ['Content-Type' => 'application/json'], + 'body' => json_encode([ + 'jsonrpc' => '2.0', + 'method' => 'initialize', + 'params' => [ + 'protocolVersion' => '2025-03-26', + 'capabilities' => new \stdClass(), + 'clientInfo' => ['name' => 'test', 'version' => '1.0'], + ], + 'id' => 1, + ]), + ]); + + // Without credentials, should get 401 + $this->assertResponseStatusCodeSame(401); + } + + public function testMcpEndpointRejectsInvalidPassword(): void + { + $profile = $this->createProfile( + firstName: 'McpTest', + role: 'ROLE_VIEWER', + password: 'correct-password', + ); + + $client = $this->createUnauthenticatedClient(); + + $client->request('POST', '/_mcp', [ + 'headers' => [ + 'Content-Type' => 'application/json', + 'X-Profile-Id' => $profile->getId(), + 'X-Profile-Password' => 'wrong-password', + ], + 'body' => json_encode([ + 'jsonrpc' => '2.0', + 'method' => 'initialize', + 'params' => [ + 'protocolVersion' => '2025-03-26', + 'capabilities' => new \stdClass(), + 'clientInfo' => ['name' => 'test', 'version' => '1.0'], + ], + 'id' => 1, + ]), + ]); + + $this->assertResponseStatusCodeSame(401); + } + + public function testMcpEndpointAcceptsValidCredentials(): void + { + $profile = $this->createProfile( + firstName: 'McpTest', + role: 'ROLE_VIEWER', + password: 'valid-password', + ); + + $client = $this->createUnauthenticatedClient(); + + $client->request('POST', '/_mcp', [ + 'headers' => [ + 'Content-Type' => 'application/json', + 'X-Profile-Id' => $profile->getId(), + 'X-Profile-Password' => 'valid-password', + ], + 'body' => json_encode([ + 'jsonrpc' => '2.0', + 'method' => 'initialize', + 'params' => [ + 'protocolVersion' => '2025-03-26', + 'capabilities' => new \stdClass(), + 'clientInfo' => ['name' => 'test', 'version' => '1.0'], + ], + 'id' => 1, + ]), + ]); + + // Should NOT be 401 — may be 200 or other MCP response + $this->assertResponseStatusCodeSame(200); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +make test FILES=tests/Mcp/Security/McpHeaderAuthenticatorTest.php +``` + +Expected: FAIL — class `McpHeaderAuthenticator` does not exist. + +- [ ] **Step 3: Create the authenticator** + +Create `src/Mcp/Security/McpHeaderAuthenticator.php`: + +```php +headers->has('X-Profile-Id') || !$request->headers->has('X-Profile-Password')) { + return false; + } + + return true; + } + + public function authenticate(Request $request): Passport + { + $profileId = $request->headers->get('X-Profile-Id', ''); + $password = $request->headers->get('X-Profile-Password', ''); + + // Rate limiting per IP — consume 1 token per attempt + $limiter = $this->mcpAuthLimiter->create($request->getClientIp() ?? 'unknown'); + $limit = $limiter->consume(1); + + if (!$limit->isAccepted()) { + $this->mcpLogger->warning('MCP auth rate limited', ['ip' => $request->getClientIp()]); + throw new CustomUserMessageAuthenticationException('Rate limited: too many authentication attempts.'); + } + + return new SelfValidatingPassport( + new UserBadge($profileId, function (string $id) use ($password, $limiter, $request): Profile { + $profile = $this->profiles->find($id); + + if (!$profile || !$profile->isActive()) { + $this->mcpLogger->warning('MCP auth failed: profile not found', ['profileId' => $id]); + throw new CustomUserMessageAuthenticationException('Authentication failed: invalid credentials.'); + } + + if (!$this->passwordHasher->isPasswordValid($profile, $password)) { + $this->mcpLogger->warning('MCP auth failed: invalid password', ['profileId' => $id]); + throw new CustomUserMessageAuthenticationException('Authentication failed: invalid credentials.'); + } + + // Reset limiter on successful auth + $limiter->reset(); + + $this->mcpLogger->info('MCP auth success', [ + 'profileId' => $id, + 'roles' => $profile->getRoles(), + 'ip' => $request->getClientIp(), + ]); + + return $profile; + }) + ); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + return null; + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response + { + $statusCode = str_contains($exception->getMessageKey(), 'Rate limited') + ? Response::HTTP_TOO_MANY_REQUESTS + : Response::HTTP_UNAUTHORIZED; + + return new JsonResponse( + ['message' => $exception->getMessageKey()], + $statusCode, + ); + } +} +``` + +- [ ] **Step 4: Add MCP firewall to security.yaml** + +Add the `mcp` firewall BEFORE the `api` firewall in `config/packages/security.yaml`: + +```yaml + firewalls: + dev: + pattern: ^/(_profiler|_wdt|assets|build)/ + security: false + + session_public: + pattern: ^/api/session/profiles?$ + security: false + + mcp: + pattern: ^/_mcp + stateless: true + custom_authenticators: + - App\Mcp\Security\McpHeaderAuthenticator + + api: + pattern: ^/api + stateless: false + custom_authenticators: + - App\Security\SessionProfileAuthenticator +``` + +Also add access control for `/_mcp` before the `/api` rules: + +```yaml + access_control: + - { path: ^/api/session/profile$, roles: PUBLIC_ACCESS } + - { path: ^/api/session/profiles, roles: PUBLIC_ACCESS, methods: [GET] } + - { path: ^/api/admin, roles: ROLE_ADMIN } + - { path: ^/api/docs, roles: PUBLIC_ACCESS } + - { path: ^/api/health$, roles: PUBLIC_ACCESS } + - { path: ^/_mcp, roles: ROLE_USER } + - { path: ^/docs, roles: PUBLIC_ACCESS } + - { path: ^/contexts, roles: PUBLIC_ACCESS } + - { path: ^/\.well-known, roles: PUBLIC_ACCESS } + - { path: ^/api, roles: ROLE_VIEWER } +``` + +- [ ] **Step 5: Run test to verify it passes** + +```bash +make test FILES=tests/Mcp/Security/McpHeaderAuthenticatorTest.php +``` + +Expected: 3 tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/Mcp/Security/McpHeaderAuthenticator.php tests/Mcp/Security/McpHeaderAuthenticatorTest.php config/packages/security.yaml +git commit -m "feat(mcp) : add McpHeaderAuthenticator with rate limiting" +``` + +--- + +### Task 3: Stdio auth — EventSubscriber for console transport + +The `McpHeaderAuthenticator` only works for HTTP requests. For stdio transport (`bin/console mcp:server`), we need to read credentials from env vars and inject a security token manually. + +**Files:** +- Create: `src/Mcp/Security/McpStdioAuthSubscriber.php` + +- [ ] **Step 1: Create the subscriber** + +Create `src/Mcp/Security/McpStdioAuthSubscriber.php`: + +```php +getCommand(); + if (!$command || !str_starts_with($command->getName() ?? '', 'mcp:')) { + return; + } + + $profileId = $_ENV['MCP_PROFILE_ID'] ?? ''; + $password = $_ENV['MCP_PROFILE_PASSWORD'] ?? ''; + + if ($profileId === '' || $password === '') { + $this->mcpLogger->error('MCP stdio: missing MCP_PROFILE_ID or MCP_PROFILE_PASSWORD env vars'); + $event->disableCommand(); + $event->getOutput()->writeln('MCP auth: MCP_PROFILE_ID and MCP_PROFILE_PASSWORD env vars required'); + return; + } + + $profile = $this->profiles->find($profileId); + + if (!$profile || !$profile->isActive()) { + $this->mcpLogger->error('MCP stdio: profile not found or inactive', ['profileId' => $profileId]); + $event->disableCommand(); + $event->getOutput()->writeln('MCP auth: invalid profile'); + return; + } + + if (!$this->passwordHasher->isPasswordValid($profile, $password)) { + $this->mcpLogger->error('MCP stdio: invalid password', ['profileId' => $profileId]); + $event->disableCommand(); + $event->getOutput()->writeln('MCP auth: invalid password'); + return; + } + + // Inject a synthetic security token so $security->getUser() works in tools + $token = new UsernamePasswordToken($profile, 'mcp', $profile->getRoles()); + $this->tokenStorage->setToken($token); + + $this->mcpLogger->info('MCP stdio auth success', [ + 'profileId' => $profileId, + 'roles' => $profile->getRoles(), + ]); + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/Mcp/Security/McpStdioAuthSubscriber.php +git commit -m "feat(mcp) : add stdio auth subscriber for console transport" +``` + +--- + +### Task 4: PoC Tool — get_dashboard_stats + +**Files:** +- Create: `src/Mcp/Tool/DashboardStatsTool.php` +- Create: `tests/Mcp/Tool/DashboardStatsToolTest.php` + +- [ ] **Step 1: Write the test** + +Create `tests/Mcp/Tool/DashboardStatsToolTest.php`: + +```php +createSite(); + $this->createMachine(site: $site); + $this->createMachine(site: $site); + $constructeur = $this->createConstructeur(); + $modelType = $this->createModelType(category: 'piece'); + $this->createPiece(modelType: $modelType, constructeur: $constructeur); + + $profile = $this->createProfile(role: 'ROLE_VIEWER', password: 'test123'); + $client = $this->createUnauthenticatedClient(); + + $response = $client->request('POST', '/_mcp', [ + 'headers' => [ + 'Content-Type' => 'application/json', + 'X-Profile-Id' => $profile->getId(), + 'X-Profile-Password' => 'test123', + ], + 'body' => json_encode([ + 'jsonrpc' => '2.0', + 'method' => 'tools/call', + 'params' => [ + 'name' => 'get_dashboard_stats', + 'arguments' => new \stdClass(), + ], + 'id' => 2, + ]), + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertArrayHasKey('result', $data); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +make test FILES=tests/Mcp/Tool/DashboardStatsToolTest.php +``` + +Expected: FAIL — tool `get_dashboard_stats` not found. + +- [ ] **Step 3: Create the tool** + +Create `src/Mcp/Tool/DashboardStatsTool.php`: + +```php + 'object', + 'properties' => new \stdClass(), + ] + )] + public function __invoke(): array + { + $unresolvedComments = (int) $this->em->createQuery( + "SELECT COUNT(c.id) FROM App\Entity\Comment c WHERE c.status = 'open'" + )->getSingleScalarResult(); + + return [ + new TextContent(text: json_encode([ + 'machines' => $this->machines->count([]), + 'pieces' => $this->pieces->count([]), + 'composants' => $this->composants->count([]), + 'products' => $this->products->count([]), + 'sites' => $this->sites->count([]), + 'unresolvedComments' => $unresolvedComments, + ], JSON_THROW_ON_ERROR)), + ]; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +make test FILES=tests/Mcp/Tool/DashboardStatsToolTest.php +``` + +Expected: PASS. + +- [ ] **Step 5: Verify stdio transport works** + +```bash +echo '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}},"id":1}' | docker exec -i -e MCP_PROFILE_ID= -e MCP_PROFILE_PASSWORD= php-inventory-apache php bin/console mcp:server +``` + +Expected: JSON-RPC response with server capabilities. + +> **Note:** This step validates the PoC. If stdio does not work, investigate the MCP bundle's console command API and adapt. Update this plan if the command name or args differ. + +- [ ] **Step 6: Commit** + +```bash +git add src/Mcp/Tool/DashboardStatsTool.php tests/Mcp/Tool/DashboardStatsToolTest.php +git commit -m "feat(mcp) : add get_dashboard_stats PoC tool" +``` + +--- + +### Task 5: Base helper trait for MCP tools + +Tools share common patterns: role checking, error formatting, pagination. Extract a reusable trait. + +**Files:** +- Create: `src/Mcp/Tool/McpToolHelper.php` + +- [ ] **Step 1: Create the helper trait** + +Create `src/Mcp/Tool/McpToolHelper.php`: + +```php +isGranted($role)) { + throw new \RuntimeException("Permission denied: {$role} required."); + } + } + + /** + * @return array{TextContent} + */ + private function jsonResponse(array $data): array + { + return [new TextContent(text: json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE))]; + } + + /** + * Throws a RuntimeException that the MCP bundle catches and converts to + * an isError: true response. The message is prefixed with the category + * so LLMs can parse error types. + * + * Verify during PoC (Task 4) that the bundle converts RuntimeException + * to proper MCP error responses. If not, adapt to the bundle's error API. + */ + private function mcpError(string $category, string $message): never + { + throw new \RuntimeException("{$category}: {$message}"); + } + + private function paginationParams(int $page = 1, int $limit = 30): array + { + $page = max(1, $page); + $limit = min(100, max(1, $limit)); + $offset = ($page - 1) * $limit; + + return ['page' => $page, 'limit' => $limit, 'offset' => $offset]; + } + + private function paginatedResponse(array $items, int $total, int $page, int $limit): array + { + return $this->jsonResponse([ + 'items' => $items, + 'total' => $total, + 'page' => $page, + 'limit' => $limit, + 'pageCount' => (int) ceil($total / $limit), + ]); + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/Mcp/Tool/McpToolHelper.php +git commit -m "feat(mcp) : add McpToolHelper trait for shared tool utilities" +``` + +--- + +## Chunk 2: CRUD Tools — Sites, Constructeurs, Products + +### Task 6: Sites CRUD tools + +**Files:** +- Create: `src/Mcp/Tool/Site/ListSitesTool.php` +- Create: `src/Mcp/Tool/Site/GetSiteTool.php` +- Create: `src/Mcp/Tool/Site/CreateSiteTool.php` +- Create: `src/Mcp/Tool/Site/UpdateSiteTool.php` +- Create: `src/Mcp/Tool/Site/DeleteSiteTool.php` +- Create: `tests/Mcp/Tool/Site/SitesCrudToolTest.php` + +- [ ] **Step 1: Write tests for all 5 site CRUD tools** + +Create `tests/Mcp/Tool/Site/SitesCrudToolTest.php` — test each tool: list returns paginated sites, get returns a single site, create makes a new site (GESTIONNAIRE), update modifies fields, delete removes. Test that VIEWER can list/get but not create/update/delete. + +Pattern for each test: +1. Create fixtures with `$this->createSite()` / `$this->createProfile()` +2. Call `POST /_mcp` with `tools/call` JSON-RPC, `name: "list_sites"`, `arguments: {page: 1, limit: 10}` +3. Assert response contains expected data + +- [ ] **Step 2: Run tests — expect failure** + +```bash +make test FILES=tests/Mcp/Tool/Site/SitesCrudToolTest.php +``` + +- [ ] **Step 3: Implement ListSitesTool** + +Create `src/Mcp/Tool/Site/ListSitesTool.php`: + +```php + 'object', + 'properties' => [ + 'page' => ['type' => 'integer', 'description' => 'Page number (1-indexed)', 'default' => 1], + 'limit' => ['type' => 'integer', 'description' => 'Items per page (max 100)', 'default' => 30], + 'search' => ['type' => 'string', 'description' => 'Search by site name'], + ], + ] + )] + public function __invoke(int $page = 1, int $limit = 30, ?string $search = null): array + { + $p = $this->paginationParams($page, $limit); + $qb = $this->sites->createQueryBuilder('s')->orderBy('s.name', 'ASC'); + + if ($search) { + $qb->andWhere('LOWER(s.name) LIKE LOWER(:search)') + ->setParameter('search', "%{$search}%"); + } + + $total = (int) (clone $qb)->select('COUNT(s.id)')->getQuery()->getSingleScalarResult(); + + $items = $qb->setFirstResult($p['offset']) + ->setMaxResults($p['limit']) + ->getQuery() + ->getArrayResult(); + + return $this->paginatedResponse($items, $total, $p['page'], $p['limit']); + } +} +``` + +- [ ] **Step 4: Implement GetSiteTool, CreateSiteTool, UpdateSiteTool, DeleteSiteTool** + +Follow the same pattern as ListSitesTool. Each tool: +- Has `#[McpTool]` attribute with name, description, inputSchema +- Uses `McpToolHelper` trait +- Get: takes `siteId: string`, returns site data or error if not found +- Create: takes `name, address?, phone?, email?, contactName?`, calls `$this->requireRole($security, 'ROLE_GESTIONNAIRE')`, persists via EntityManager +- Update: takes `siteId` + optional fields, merges changes +- Delete: takes `siteId`, removes entity + +- [ ] **Step 5: Run tests — expect pass** + +```bash +make test FILES=tests/Mcp/Tool/Site/SitesCrudToolTest.php +``` + +- [ ] **Step 6: Run php-cs-fixer** + +```bash +make php-cs-fixer-allow-risky +``` + +- [ ] **Step 7: Commit** + +```bash +git add src/Mcp/Tool/Site/ tests/Mcp/Tool/Site/ +git commit -m "feat(mcp) : add Sites CRUD tools (list, get, create, update, delete)" +``` + +--- + +### Task 7: Constructeurs CRUD tools + +**Files:** +- Create: `src/Mcp/Tool/Constructeur/ListConstructeursTool.php` +- Create: `src/Mcp/Tool/Constructeur/GetConstructeurTool.php` +- Create: `src/Mcp/Tool/Constructeur/CreateConstructeurTool.php` +- Create: `src/Mcp/Tool/Constructeur/UpdateConstructeurTool.php` +- Create: `src/Mcp/Tool/Constructeur/DeleteConstructeurTool.php` +- Create: `tests/Mcp/Tool/Constructeur/ConstructeursCrudToolTest.php` + +- [ ] **Step 1: Write tests** +- [ ] **Step 2: Run tests — expect failure** +- [ ] **Step 3: Implement all 5 tools** (same pattern as Sites) +- [ ] **Step 4: Run tests — expect pass** +- [ ] **Step 5: Run php-cs-fixer** +- [ ] **Step 6: Commit** + +```bash +git add src/Mcp/Tool/Constructeur/ tests/Mcp/Tool/Constructeur/ +git commit -m "feat(mcp) : add Constructeurs CRUD tools" +``` + +--- + +### Task 8: Products CRUD tools + +**Files:** +- Create: `src/Mcp/Tool/Product/ListProductsTool.php` +- Create: `src/Mcp/Tool/Product/GetProductTool.php` +- Create: `src/Mcp/Tool/Product/CreateProductTool.php` +- Create: `src/Mcp/Tool/Product/UpdateProductTool.php` +- Create: `src/Mcp/Tool/Product/DeleteProductTool.php` +- Create: `tests/Mcp/Tool/Product/ProductsCrudToolTest.php` + +- [ ] **Step 1: Write tests** + +Important: `prix` (price) must be sent as **string**, not number (see project memory `feedback_prix_type.md`). + +- [ ] **Step 2: Run tests — expect failure** +- [ ] **Step 3: Implement all 5 tools** + +`CreateProductTool` takes: `name: string`, `reference?: string`, `modelTypeId?: string`, `prix?: string` (STRING not number), `constructeurIds?: string[]`. + +- [ ] **Step 4: Run tests — expect pass** +- [ ] **Step 5: Run php-cs-fixer** +- [ ] **Step 6: Commit** + +```bash +git add src/Mcp/Tool/Product/ tests/Mcp/Tool/Product/ +git commit -m "feat(mcp) : add Products CRUD tools" +``` + +--- + +## Chunk 3: CRUD Tools — Pieces, Composants, Machines + +### Task 9: Pieces CRUD tools + +**Files:** +- Create: `src/Mcp/Tool/Piece/ListPiecesTool.php` +- Create: `src/Mcp/Tool/Piece/GetPieceTool.php` +- Create: `src/Mcp/Tool/Piece/CreatePieceTool.php` +- Create: `src/Mcp/Tool/Piece/UpdatePieceTool.php` +- Create: `src/Mcp/Tool/Piece/DeletePieceTool.php` +- Create: `tests/Mcp/Tool/Piece/PiecesCrudToolTest.php` + +- [ ] **Step 1: Write tests** + +`CreatePieceTool` test must verify that after creation, the response includes auto-generated product slots from the ModelType's skeleton requirements. + +- [ ] **Step 2: Run tests — expect failure** +- [ ] **Step 3: Implement all 5 tools** + +`CreatePieceTool`: after persisting the Piece, read back its `PieceProductSlot` entities (auto-created by Doctrine lifecycle) and include them in the response. + +`GetPieceTool`: include product slots in the response. + +- [ ] **Step 4: Run tests — expect pass** +- [ ] **Step 5: Run php-cs-fixer** +- [ ] **Step 6: Commit** + +```bash +git add src/Mcp/Tool/Piece/ tests/Mcp/Tool/Piece/ +git commit -m "feat(mcp) : add Pieces CRUD tools with slot auto-generation" +``` + +--- + +### Task 10: Composants CRUD tools + +**Files:** +- Create: `src/Mcp/Tool/Composant/ListComposantsTool.php` +- Create: `src/Mcp/Tool/Composant/GetComposantTool.php` +- Create: `src/Mcp/Tool/Composant/CreateComposantTool.php` +- Create: `src/Mcp/Tool/Composant/UpdateComposantTool.php` +- Create: `src/Mcp/Tool/Composant/DeleteComposantTool.php` +- Create: `tests/Mcp/Tool/Composant/ComposantsCrudToolTest.php` + +- [ ] **Step 1: Write tests** + +`CreateComposantTool` test must verify that the response includes auto-generated slots: `ComposantPieceSlot`, `ComposantProductSlot`, `ComposantSubcomponentSlot`. + +- [ ] **Step 2: Run tests — expect failure** +- [ ] **Step 3: Implement all 5 tools** + +`CreateComposantTool`: after persisting, read back all 3 types of slots and include in response. + +`GetComposantTool`: include all slots with their state (selectedPieceId/selectedProductId/selectedComposantId + requirement name). + +- [ ] **Step 4: Run tests — expect pass** +- [ ] **Step 5: Run php-cs-fixer** +- [ ] **Step 6: Commit** + +```bash +git add src/Mcp/Tool/Composant/ tests/Mcp/Tool/Composant/ +git commit -m "feat(mcp) : add Composants CRUD tools with slot auto-generation" +``` + +--- + +### Task 11: Machines CRUD tools + +**Files:** +- Create: `src/Mcp/Tool/Machine/ListMachinesTool.php` +- Create: `src/Mcp/Tool/Machine/GetMachineTool.php` +- Create: `src/Mcp/Tool/Machine/CreateMachineTool.php` +- Create: `src/Mcp/Tool/Machine/UpdateMachineTool.php` +- Create: `src/Mcp/Tool/Machine/DeleteMachineTool.php` +- Create: `tests/Mcp/Tool/Machine/MachinesCrudToolTest.php` + +- [ ] **Step 1: Write tests** +- [ ] **Step 2: Run tests — expect failure** +- [ ] **Step 3: Implement all 5 tools** + +`CreateMachineTool`: takes `name, reference?, siteId, constructeurIds?`. Returns machine data. + +- [ ] **Step 4: Run tests — expect pass** +- [ ] **Step 5: Run php-cs-fixer** +- [ ] **Step 6: Commit** + +```bash +git add src/Mcp/Tool/Machine/ tests/Mcp/Tool/Machine/ +git commit -m "feat(mcp) : add Machines CRUD tools" +``` + +--- + +## Chunk 4: Specialized Tools — Slots, Links, Structure, Clone + +### Task 12: Slot tools (list_slots, update_slots) + +**Files:** +- Create: `src/Mcp/Tool/Slot/ListSlotsTool.php` +- Create: `src/Mcp/Tool/Slot/UpdateSlotsTool.php` +- Create: `tests/Mcp/Tool/Slot/SlotsToolTest.php` + +- [ ] **Step 1: Write tests** + +Test `list_slots` for both composant and piece entity types. Test `update_slots` by selecting a piece into a composant's piece slot. + +- [ ] **Step 2: Run tests — expect failure** +- [ ] **Step 3: Implement ListSlotsTool** + +Accepts `entityType: "composant"|"piece"`, `entityId: string`. Queries the appropriate slot repositories (`ComposantPieceSlotRepository`, `ComposantProductSlotRepository`, `ComposantSubcomponentSlotRepository` for composant; `PieceProductSlotRepository` for piece). Returns slots with requirement name, selected entity, position. + +- [ ] **Step 4: Implement UpdateSlotsTool** + +Accepts `slots: [{slotId, slotType: "piece"|"product"|"subcomponent", selectedPieceId?|selectedProductId?|selectedComposantId?}]`. Uses the existing slot controllers' logic: find slot, set selected entity, flush. + +- [ ] **Step 5: Run tests — expect pass** +- [ ] **Step 6: Run php-cs-fixer and commit** + +```bash +git add src/Mcp/Tool/Slot/ tests/Mcp/Tool/Slot/ +git commit -m "feat(mcp) : add list_slots and update_slots tools" +``` + +--- + +### Task 13: Machine links tools + +**Files:** +- Create: `src/Mcp/Tool/Machine/ListMachineLinksTool.php` +- Create: `src/Mcp/Tool/Machine/AddMachineLinksTool.php` +- Create: `src/Mcp/Tool/Machine/UpdateMachineLinkTool.php` +- Create: `src/Mcp/Tool/Machine/RemoveMachineLinkTool.php` +- Create: `tests/Mcp/Tool/Machine/MachineLinksToolTest.php` + +- [ ] **Step 1: Write tests** +- [ ] **Step 2: Run tests — expect failure** +- [ ] **Step 3: Implement all 4 tools** + +`AddMachineLinksTool`: accepts `machineId`, `links: [{type: "composant"|"piece"|"product", entityId, quantity?, parentLinkId?}]`. Creates `MachineComponentLink`, `MachinePieceLink`, or `MachineProductLink` accordingly. + +`ListMachineLinksTool`: returns all links for a machine, grouped by type. + +- [ ] **Step 4: Run tests — expect pass** +- [ ] **Step 5: Run php-cs-fixer and commit** + +```bash +git add src/Mcp/Tool/Machine/ListMachineLinksTool.php src/Mcp/Tool/Machine/AddMachineLinksTool.php src/Mcp/Tool/Machine/UpdateMachineLinkTool.php src/Mcp/Tool/Machine/RemoveMachineLinkTool.php tests/Mcp/Tool/Machine/MachineLinksToolTest.php +git commit -m "feat(mcp) : add Machine links tools (list, add, update, remove)" +``` + +--- + +### Task 14: Machine structure and clone tools + +**Files:** +- Create: `src/Mcp/Tool/Machine/MachineStructureTool.php` +- Create: `src/Mcp/Tool/Machine/CloneMachineTool.php` +- Create: `tests/Mcp/Tool/Machine/MachineStructureToolTest.php` + +- [ ] **Step 1: Write tests** + +Test `get_machine_structure` returns the full hierarchy. Test `clone_machine` creates a copy with all links. + +- [ ] **Step 2: Run tests — expect failure** +- [ ] **Step 3: Implement MachineStructureTool** + +Reuse the logic from `MachineStructureController::getStructure()` — query machine with all links, composants, pieces, products, custom fields, and return as nested JSON. + +- [ ] **Step 4: Implement CloneMachineTool** + +Reuse the logic from `MachineStructureController::cloneMachine()`. Accepts `machineId, name, siteId, reference?`. Returns the new machine's structure. + +- [ ] **Step 5: Run tests — expect pass** +- [ ] **Step 6: Run php-cs-fixer and commit** + +```bash +git add src/Mcp/Tool/Machine/MachineStructureTool.php src/Mcp/Tool/Machine/CloneMachineTool.php tests/Mcp/Tool/Machine/MachineStructureToolTest.php +git commit -m "feat(mcp) : add get_machine_structure and clone_machine tools" +``` + +--- + +## Chunk 5: Business Tools — Search, History, Comments, Custom Fields, ModelTypes + +### Task 15: search_inventory tool + +**Files:** +- Create: `src/Mcp/Tool/SearchInventoryTool.php` +- Create: `tests/Mcp/Tool/SearchInventoryToolTest.php` + +- [ ] **Step 1: Write tests** + +Test searching across multiple entity types (machines, pieces, composants, products, sites, constructeurs) by name/reference. + +- [ ] **Step 2: Run tests — expect failure** +- [ ] **Step 3: Implement SearchInventoryTool** + +Accepts `query: string`, `types?: string[]` (defaults to all), `limit?: int` (default 20). Runs LIKE queries across entity tables, merges and sorts results by relevance. Returns `[{type, id, name, reference, ...}]`. + +- [ ] **Step 4: Run tests — expect pass** +- [ ] **Step 5: Run php-cs-fixer and commit** + +```bash +git add src/Mcp/Tool/SearchInventoryTool.php tests/Mcp/Tool/SearchInventoryToolTest.php +git commit -m "feat(mcp) : add search_inventory global search tool" +``` + +--- + +### Task 16: History and activity log tools + +**Files:** +- Create: `src/Mcp/Tool/EntityHistoryTool.php` +- Create: `src/Mcp/Tool/ActivityLogTool.php` +- Create: `tests/Mcp/Tool/HistoryToolsTest.php` + +- [ ] **Step 1: Write tests** +- [ ] **Step 2: Run tests — expect failure** +- [ ] **Step 3: Implement EntityHistoryTool** + +Reuse logic from `EntityHistoryController`. Accepts `entityType: string`, `entityId: string`. Queries `AuditLog` by entity type/id. + +- [ ] **Step 4: Implement ActivityLogTool** + +Reuse logic from `ActivityLogController`. Accepts `page?, limit?, entityType?, action?`. + +- [ ] **Step 5: Run tests — expect pass** +- [ ] **Step 6: Run php-cs-fixer and commit** + +```bash +git add src/Mcp/Tool/EntityHistoryTool.php src/Mcp/Tool/ActivityLogTool.php tests/Mcp/Tool/HistoryToolsTest.php +git commit -m "feat(mcp) : add entity history and activity log tools" +``` + +--- + +### Task 17: Comment tools + +**Files:** +- Create: `src/Mcp/Tool/Comment/ListCommentsTool.php` +- Create: `src/Mcp/Tool/Comment/CreateCommentTool.php` +- Create: `src/Mcp/Tool/Comment/ResolveCommentTool.php` +- Create: `src/Mcp/Tool/Comment/UnresolvedCountTool.php` +- Create: `tests/Mcp/Tool/Comment/CommentsToolTest.php` + +- [ ] **Step 1: Write tests** +- [ ] **Step 2: Run tests — expect failure** +- [ ] **Step 3: Implement all 4 comment tools** + +Reuse logic from `CommentController`. `CreateCommentTool` requires ROLE_VIEWER. `ResolveCommentTool` requires ROLE_GESTIONNAIRE. + +- [ ] **Step 4: Run tests — expect pass** +- [ ] **Step 5: Run php-cs-fixer and commit** + +```bash +git add src/Mcp/Tool/Comment/ tests/Mcp/Tool/Comment/ +git commit -m "feat(mcp) : add comment tools (list, create, resolve, unresolved count)" +``` + +--- + +### Task 18: Custom field tools + +**Files:** +- Create: `src/Mcp/Tool/CustomField/ListCustomFieldValuesTool.php` +- Create: `src/Mcp/Tool/CustomField/UpsertCustomFieldValuesTool.php` +- Create: `src/Mcp/Tool/CustomField/DeleteCustomFieldValueTool.php` +- Create: `tests/Mcp/Tool/CustomField/CustomFieldToolsTest.php` + +- [ ] **Step 1: Write tests** +- [ ] **Step 2: Run tests — expect failure** +- [ ] **Step 3: Implement all 3 tools** + +Reuse logic from `CustomFieldValueController`. `UpsertCustomFieldValuesTool` accepts `entityType, entityId, fields: [{customFieldId OR (customFieldName + customFieldType), value}]`. + +- [ ] **Step 4: Run tests — expect pass** +- [ ] **Step 5: Run php-cs-fixer and commit** + +```bash +git add src/Mcp/Tool/CustomField/ tests/Mcp/Tool/CustomField/ +git commit -m "feat(mcp) : add custom field value tools (list, upsert, delete)" +``` + +--- + +### Task 19: Document tools + +**Files:** +- Create: `src/Mcp/Tool/Document/ListDocumentsTool.php` +- Create: `src/Mcp/Tool/Document/DeleteDocumentTool.php` +- Create: `tests/Mcp/Tool/Document/DocumentToolsTest.php` + +- [ ] **Step 1: Write tests** +- [ ] **Step 2: Run tests — expect failure** +- [ ] **Step 3: Implement both tools** + +Reuse logic from `DocumentQueryController` for list and `DocumentRepository` for delete. No upload tool (documented limitation). + +- [ ] **Step 4: Run tests — expect pass** +- [ ] **Step 5: Run php-cs-fixer and commit** + +```bash +git add src/Mcp/Tool/Document/ tests/Mcp/Tool/Document/ +git commit -m "feat(mcp) : add document tools (list, delete)" +``` + +--- + +### Task 20: ModelType tools + +**Files:** +- Create: `src/Mcp/Tool/ModelType/ListModelTypesTool.php` +- Create: `src/Mcp/Tool/ModelType/GetModelTypeTool.php` +- Create: `src/Mcp/Tool/ModelType/CreateModelTypeTool.php` +- Create: `src/Mcp/Tool/ModelType/UpdateModelTypeTool.php` +- Create: `src/Mcp/Tool/ModelType/DeleteModelTypeTool.php` +- Create: `src/Mcp/Tool/ModelType/SyncModelTypeTool.php` +- Create: `tests/Mcp/Tool/ModelType/ModelTypeToolsTest.php` + +- [ ] **Step 1: Write tests** + +`GetModelTypeTool` test must verify skeleton requirements and custom fields are included. `SyncModelTypeTool` test must verify preview and sync actions. + +- [ ] **Step 2: Run tests — expect failure** +- [ ] **Step 3: Implement CRUD tools** + +`GetModelTypeTool` returns full detail including `SkeletonPieceRequirement`, `SkeletonProductRequirement`, `SkeletonSubcomponentRequirement` with their custom fields. + +- [ ] **Step 4: Implement SyncModelTypeTool** + +Reuse logic from `ModelTypeSyncController`. Accepts `modelTypeId, action: "preview"|"sync", structure?, confirmDeletions?, confirmTypeChanges?`. + +- [ ] **Step 5: Run tests — expect pass** +- [ ] **Step 6: Run php-cs-fixer and commit** + +```bash +git add src/Mcp/Tool/ModelType/ tests/Mcp/Tool/ModelType/ +git commit -m "feat(mcp) : add ModelType tools (CRUD + sync)" +``` + +--- + +## Chunk 6: Resources, Documentation, Final Validation + +### Task 21: MCP Resources + +**Files:** +- Create: `src/Mcp/Resource/SchemaResource.php` +- Create: `src/Mcp/Resource/ModelTypesResource.php` +- Create: `src/Mcp/Resource/RolesResource.php` +- Create: `src/Mcp/Resource/StatsResource.php` + +- [ ] **Step 1: Implement SchemaResource** + +```php + [ + 'fields' => ['id (string, CUID)', 'name (string)', 'reference (string, nullable)', 'siteId (string)', 'createdAt', 'updatedAt'], + 'relationships' => ['site (Site)', 'constructeurs (Constructeur[])', 'componentLinks (MachineComponentLink[])', 'pieceLinks (MachinePieceLink[])', 'productLinks (MachineProductLink[])', 'customFieldValues (CustomFieldValue[])', 'documents (Document[])'], + ], + 'Composant' => [ + 'fields' => ['id (string, CUID)', 'name (string)', 'reference (string, nullable)', 'modelTypeId (string, nullable)', 'createdAt', 'updatedAt'], + 'relationships' => ['modelType (ModelType)', 'constructeurs (Constructeur[])', 'pieceSlots (ComposantPieceSlot[])', 'productSlots (ComposantProductSlot[])', 'subcomponentSlots (ComposantSubcomponentSlot[])', 'customFieldValues (CustomFieldValue[])', 'documents (Document[])'], + ], + 'Piece' => [ + 'fields' => ['id (string, CUID)', 'name (string)', 'reference (string, nullable)', 'modelTypeId (string, nullable)', 'createdAt', 'updatedAt'], + 'relationships' => ['modelType (ModelType)', 'constructeurs (Constructeur[])', 'productSlots (PieceProductSlot[])', 'customFieldValues (CustomFieldValue[])', 'documents (Document[])'], + ], + 'Product' => [ + 'fields' => ['id (string, CUID)', 'name (string)', 'reference (string, nullable)', 'prix (string, nullable)', 'modelTypeId (string, nullable)', 'createdAt', 'updatedAt'], + 'relationships' => ['modelType (ModelType)', 'constructeurs (Constructeur[])', 'customFieldValues (CustomFieldValue[])', 'documents (Document[])'], + ], + 'Site' => [ + 'fields' => ['id (string, CUID)', 'name (string)', 'address (string, nullable)', 'phone (string, nullable)', 'email (string, nullable)', 'contactName (string, nullable)', 'createdAt', 'updatedAt'], + 'relationships' => ['machines (Machine[])', 'documents (Document[])'], + ], + 'Constructeur' => [ + 'fields' => ['id (string, CUID)', 'name (string)', 'website (string, nullable)', 'phone (string, nullable)', 'email (string, nullable)', 'createdAt', 'updatedAt'], + 'relationships' => ['machines (Machine[])', 'composants (Composant[])', 'pieces (Piece[])', 'products (Product[])'], + ], + 'ModelType' => [ + 'fields' => ['id (string, CUID)', 'name (string)', 'category (string: machine|composant|piece|product)', 'createdAt', 'updatedAt'], + 'relationships' => ['skeletonPieceRequirements[]', 'skeletonProductRequirements[]', 'skeletonSubcomponentRequirements[]', 'customFields (CustomField[])'], + ], + ]; + + return [new TextContent(text: json_encode($schema, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE))]; + } +} +``` + +- [ ] **Step 2: Implement RolesResource, StatsResource, ModelTypesResource** + +`RolesResource`: static content describing role hierarchy and tool permissions. +`StatsResource`: same logic as `DashboardStatsTool` but as a resource. +`ModelTypesResource`: resource template with `{category}` parameter showing model types and their skeleton requirements. + +- [ ] **Step 3: Run php-cs-fixer and commit** + +```bash +git add src/Mcp/Resource/ +git commit -m "feat(mcp) : add MCP resources (schema, roles, stats, model types)" +``` + +--- + +### Task 22: User documentation + +**Files:** +- Create: `docs/mcp/README.md` + +- [ ] **Step 1: Write the documentation** + +Create `docs/mcp/README.md` with sections as defined in the spec (section 14): +1. Introduction — what is MCP Inventory, which clients are supported +2. Prerequisites — profile with sufficient role, tunnel access +3. Client configuration — copy-paste examples for Claude Code (stdio via Docker), Claude Desktop (HTTP), ChatGPT Desktop (HTTP), Codex (HTTP) +4. Tool catalogue — table of all ~55 tools with name, description, parameters, required role +5. Guided workflows — step-by-step for creating machine, composant, piece, product +6. Resources — URIs and content +7. Roles & permissions — role hierarchy and tool mapping +8. Error format — categories and examples +9. Known limitations — document upload +10. Troubleshooting — common errors + +- [ ] **Step 2: Commit** + +```bash +git add docs/mcp/README.md +git commit -m "docs(mcp) : add user documentation for MCP server" +``` + +--- + +### Task 23: Create .mcp.json for Claude Code + +**Files:** +- Create: `.mcp.json` + +- [ ] **Step 1: Create .mcp.json** + +Create `.mcp.json` at project root: + +```json +{ + "mcpServers": { + "inventory": { + "command": "docker", + "args": [ + "exec", "-i", + "-e", "MCP_PROFILE_ID=", + "-e", "MCP_PROFILE_PASSWORD=", + "php-inventory-apache", + "php", "bin/console", "mcp:server" + ] + } + } +} +``` + +> **Note:** This file contains credentials placeholders. Add `.mcp.json` to `.gitignore` if you don't want it committed, or use a `.mcp.json.example` template. + +- [ ] **Step 2: Commit** + +```bash +git add .mcp.json +git commit -m "feat(mcp) : add .mcp.json for Claude Code stdio transport" +``` + +--- + +### Task 24: Update CLAUDE.md with MCP documentation + +**Files:** +- Modify: `CLAUDE.md` + +- [ ] **Step 1: Add MCP section to CLAUDE.md** + +Add a new section after "## Architecture Backend" covering: +- New directory `src/Mcp/` — Tools, Resources, Security +- MCP endpoint `/_mcp` with dedicated firewall +- Console command `mcp:server` for stdio transport +- Tool naming convention: `verb_entity` (e.g., `list_machines`, `create_composant`) +- `#[McpTool]` attribute pattern with `inputSchema` +- Return type: `array{TextContent}` with JSON-encoded content +- Auth: `McpHeaderAuthenticator` for HTTP, `McpStdioAuthSubscriber` for stdio + +- [ ] **Step 2: Commit** + +```bash +git add CLAUDE.md +git commit -m "docs(mcp) : add MCP architecture section to CLAUDE.md" +``` + +--- + +### Task 25: Full test suite run and final validation + +- [ ] **Step 1: Run all MCP tests** + +```bash +make test FILES=tests/Mcp/ +``` + +Expected: all tests pass. + +- [ ] **Step 2: Run full project test suite** + +```bash +make test +``` + +Expected: no regressions — existing tests still pass. + +- [ ] **Step 3: Run php-cs-fixer on all MCP files** + +```bash +make php-cs-fixer-allow-risky +``` + +- [ ] **Step 4: Test stdio transport end-to-end** + +```bash +echo '{"jsonrpc":"2.0","method":"tools/list","params":{},"id":1}' | docker exec -i -e MCP_PROFILE_ID= -e MCP_PROFILE_PASSWORD= php-inventory-apache php bin/console mcp:server +``` + +Expected: JSON response listing all ~55 tools. + +- [ ] **Step 5: Test HTTP transport end-to-end** + +```bash +curl -X POST http://localhost:8081/_mcp \ + -H "Content-Type: application/json" \ + -H "X-Profile-Id: " \ + -H "X-Profile-Password: " \ + -d '{"jsonrpc":"2.0","method":"tools/list","params":{},"id":1}' +``` + +Expected: JSON response listing all tools. + +- [ ] **Step 6: Final commit (if any remaining unstaged files)** + +```bash +git status +# Only add specific files if there are remaining changes +git commit -m "feat(mcp) : finalize MCP server implementation" +``` diff --git a/docs/superpowers/specs/2026-03-16-mcp-server-design.md b/docs/superpowers/specs/2026-03-16-mcp-server-design.md new file mode 100644 index 0000000..35bfc18 --- /dev/null +++ b/docs/superpowers/specs/2026-03-16-mcp-server-design.md @@ -0,0 +1,669 @@ +# MCP Server — Inventory Project — Design Spec + +**Date :** 2026-03-16 +**Version projet :** 1.9.1 +**Statut :** Draft (post-review v2) + +--- + +## 1. Objectif + +Exposer l'intégralité de l'API Inventory (machines, pièces, composants, produits, sites, constructeurs, custom fields, documents, commentaires, audit) via un serveur MCP (Model Context Protocol) intégré directement dans l'application Symfony. + +Le serveur doit être compatible avec tous les clients MCP majeurs : Claude Code, Claude Desktop, ChatGPT Desktop, Codex, et tout client supportant le protocole MCP. + +## 2. Contraintes + +| Contrainte | Détail | +|---|---| +| **Réseau** | Machine hébergée sur un réseau fermé d'entreprise. Les clients distants (Claude Desktop, ChatGPT, Codex) accèdent via un tunnel chiffré (Cloudflare/WireGuard/SSH) | +| **Auth** | Pass-through : chaque client fournit ses propres credentials (profileId + password). Le serveur MCP charge le profil correspondant et applique ses rôles. Les actions sont traçables par utilisateur dans l'audit log | +| **Transport** | Dual : stdio pour usage local (Claude Code sur la même machine) + HTTP Streamable/SSE pour clients distants via tunnel | +| **Stack** | PHP / Symfony 8.0 — le serveur MCP vit dans l'application existante, pas de service séparé | +| **Scope** | Lecture + écriture complète — les outils couvrent tout le CRUD + les opérations métier | + +## 3. Stack technique + +| Composant | Choix | +|---|---| +| SDK MCP | `symfony/mcp-bundle` v0.6.0 + `mcp/sdk` ^0.4 (officiel Symfony + PHP Foundation + Anthropic) | +| Transport stdio | `bin/console mcp:server` (dans le container Docker) | +| Transport HTTP | Endpoint `/_mcp` sur le même port que l'API (8081) | +| Auth HTTP | Custom Symfony Authenticator (`McpHeaderAuthenticator`) intégré au firewall Symfony | +| Auth stdio | Token synthétique chargé depuis `$_ENV` au boot | +| Rate limiting | `symfony/rate-limiter` sur les tentatives d'auth échouées | +| Accès données | Repositories Doctrine directs (pas de hop HTTP interne) | + +**Note :** Le bundle est expérimental et non couvert par la BC Promise de Symfony. L'implémentation inclut un spike/PoC initial (étape 1 du plan) pour valider la compatibilité de l'API réelle du bundle avec ce design. + +## 4. Architecture + +``` +┌─────────────────────────────────────────────────────┐ +│ Docker Compose (réseau fermé entreprise) │ +│ │ +│ ┌─────────────────────────────────────────────┐ │ +│ │ php-inventory-apache (Symfony 8) │ │ +│ │ │ │ +│ │ /api/* ← API REST existante │ │ +│ │ /_mcp ← Endpoint MCP HTTP (SSE) │ │ +│ │ bin/console mcp:server ← Transport stdio │ │ +│ │ │ │ +│ │ Firewall Symfony : │ │ +│ │ ^/api → SessionProfileAuthenticator │ │ +│ │ ^/_mcp → McpHeaderAuthenticator │ │ +│ │ │ │ +│ │ src/Mcp/Tool/ ← Tools MCP │ │ +│ │ src/Mcp/Resource/ ← Resources MCP │ │ +│ │ src/Mcp/Security/ ← Authenticator + Guard │ │ +│ └──────────┬───────────────────────────────────┘ │ +│ │ réseau Docker interne │ +│ ┌──────────▼──────────┐ │ +│ │ PostgreSQL 16 │ │ +│ └─────────────────────┘ │ +└──────────────────┬──────────────────────────────────┘ + │ tunnel (chiffré) + ┌──────────────▼──────────────────┐ + │ Postes utilisateurs │ + │ - Claude Desktop → HTTP/SSE │ + │ - ChatGPT Desktop → HTTP/SSE │ + │ - Codex → HTTP/SSE │ + │ - Claude Code local → stdio │ + └─────────────────────────────────┘ +``` + +Le serveur MCP accède directement aux repositories Doctrine et aux services Symfony existants. Pas de double sérialisation — les tools appellent les mêmes repositories/services que les controllers REST. + +## 5. Authentification pass-through + +### 5.1 Firewall Symfony — intégration sécurité + +Un firewall dédié pour `/_mcp` avec un authenticator custom. Cela garantit que `$security->getUser()` retourne le bon Profile, que la hiérarchie des rôles fonctionne via `is_granted()`, et que l'audit log trace le bon acteur. + +```yaml +# config/packages/security.yaml (ajout) +security: + firewalls: + mcp: + pattern: ^/_mcp + stateless: true + custom_authenticators: + - App\Mcp\Security\McpHeaderAuthenticator +``` + +Le `McpHeaderAuthenticator` implémente `AuthenticatorInterface` : +1. Extrait `X-Profile-Id` et `X-Profile-Password` des headers +2. Charge le profil via `ProfileRepository` +3. Vérifie le password hash via `UserPasswordHasherInterface` +4. Retourne un `Passport` avec le Profile comme User +5. Symfony gère le reste (token, rôles, hiérarchie) + +Cela permet à `AbstractAuditSubscriber.resolveActorProfileId()` de résoudre l'acteur via `$security->getUser()` sans aucune modification du code existant. + +### 5.2 Transport stdio — token synthétique + +Pour le transport stdio (pas de requête HTTP), un `EventSubscriber` sur `console.command` (quand la commande est `mcp:server`) : +1. Lit `MCP_PROFILE_ID` et `MCP_PROFILE_PASSWORD` depuis `$_ENV` +2. Valide les credentials +3. Injecte un `UsernamePasswordToken` synthétique dans le `TokenStorage` avec le Profile + +### 5.3 Rate limiting — protection brute-force + +```yaml +# config/packages/rate_limiter.yaml +framework: + rate_limiter: + mcp_auth: + policy: sliding_window + limit: 5 + interval: '1 minute' +``` + +Le `McpHeaderAuthenticator` consomme le rate limiter sur chaque tentative échouée (clé = IP). Après 5 échecs en 1 minute, toute tentative est rejetée avec une erreur MCP `429 Too Many Requests`. + +### 5.4 Vérification des rôles + +Chaque tool déclare un rôle minimum. L'authenticator Symfony gère la hiérarchie : + +| Rôle | Droits MCP | +|---|---| +| `ROLE_VIEWER` | Tous les tools de lecture (list, get, search, history) | +| `ROLE_GESTIONNAIRE` | Lecture + écriture (create, update, delete, slots, clone) | +| `ROLE_ADMIN` | Tout + gestion profils | + +Les tools utilisent `$this->security->isGranted('ROLE_XXX')` pour vérifier, bénéficiant de la hiérarchie Symfony standard. + +## 6. Catalogue des Tools MCP + +### 6.1 Tools de haut niveau (métier) + +| Tool | Description | Paramètres principaux | Rôle min | +|---|---|---|---| +| `search_inventory` | Recherche globale dans toutes les entités (machines, pièces, composants, produits, sites, constructeurs) | `query: string`, `types?: string[]`, `limit?: int` | VIEWER | +| `get_machine_structure` | Hiérarchie complète d'une machine : composants, pièces, produits, custom fields, slots | `machineId: string` | VIEWER | +| `clone_machine` | Clone une machine avec sa structure complète | `machineId: string`, `name: string`, `siteId: string`, `reference?: string` | GESTIONNAIRE | +| `get_entity_history` | Historique d'audit d'une entité | `entityType: string`, `entityId: string` | VIEWER | +| `get_activity_log` | Journal d'activité global | `page?: int`, `limit?: int`, `entityType?: string`, `action?: string` | VIEWER | +| `get_dashboard_stats` | Compteurs globaux (machines, pièces, composants, produits, commentaires ouverts) | aucun | VIEWER | +| `sync_model_type` | Preview ou exécution de la synchronisation skeleton d'un ModelType | `modelTypeId: string`, `action: "preview"\|"sync"`, `structure?: object` | GESTIONNAIRE | + +### 6.2 Tools CRUD — Machines + +| Tool | Description | Rôle min | +|---|---|---| +| `list_machines` | Lister les machines avec filtres (nom, référence, site) et pagination | VIEWER | +| `get_machine` | Détail d'une machine par ID | VIEWER | +| `create_machine` | Créer une machine (nom, référence, siteId, constructeurs) | GESTIONNAIRE | +| `update_machine` | Mise à jour partielle d'une machine | GESTIONNAIRE | +| `delete_machine` | Supprimer une machine | GESTIONNAIRE | + +### 6.3 Tools CRUD — Composants + +| Tool | Description | Rôle min | +|---|---|---| +| `list_composants` | Lister les composants avec filtres et pagination | VIEWER | +| `get_composant` | Détail d'un composant par ID (incluant ses slots) | VIEWER | +| `create_composant` | Créer un composant (nom, référence, modelTypeId, constructeurs). Retourne l'ID + les slots vides auto-générés | GESTIONNAIRE | +| `update_composant` | Mise à jour partielle | GESTIONNAIRE | +| `delete_composant` | Supprimer un composant | GESTIONNAIRE | + +### 6.4 Tools CRUD — Pièces + +| Tool | Description | Rôle min | +|---|---|---| +| `list_pieces` | Lister les pièces avec filtres et pagination | VIEWER | +| `get_piece` | Détail d'une pièce par ID (incluant ses product-slots) | VIEWER | +| `create_piece` | Créer une pièce (nom, référence, modelTypeId, constructeurs). Retourne l'ID + product-slots auto-générés | GESTIONNAIRE | +| `update_piece` | Mise à jour partielle | GESTIONNAIRE | +| `delete_piece` | Supprimer une pièce | GESTIONNAIRE | + +### 6.5 Tools CRUD — Produits + +| Tool | Description | Rôle min | +|---|---|---| +| `list_products` | Lister les produits avec filtres et pagination | VIEWER | +| `get_product` | Détail d'un produit par ID | VIEWER | +| `create_product` | Créer un produit (nom, référence, modelTypeId, prix (string), constructeurs) | GESTIONNAIRE | +| `update_product` | Mise à jour partielle | GESTIONNAIRE | +| `delete_product` | Supprimer un produit | GESTIONNAIRE | + +### 6.6 Tools CRUD — Sites + +| Tool | Description | Rôle min | +|---|---|---| +| `list_sites` | Lister les sites | VIEWER | +| `get_site` | Détail d'un site par ID | VIEWER | +| `create_site` | Créer un site | GESTIONNAIRE | +| `update_site` | Mise à jour partielle | GESTIONNAIRE | +| `delete_site` | Supprimer un site | GESTIONNAIRE | + +### 6.7 Tools CRUD — Constructeurs + +| Tool | Description | Rôle min | +|---|---|---| +| `list_constructeurs` | Lister les constructeurs/fournisseurs | VIEWER | +| `get_constructeur` | Détail d'un constructeur par ID | VIEWER | +| `create_constructeur` | Créer un constructeur | GESTIONNAIRE | +| `update_constructeur` | Mise à jour partielle | GESTIONNAIRE | +| `delete_constructeur` | Supprimer un constructeur | GESTIONNAIRE | + +### 6.8 Tools — Commentaires (splittés) + +| Tool | Description | Rôle min | +|---|---|---| +| `list_comments` | Lister les commentaires d'une entité | VIEWER | +| `create_comment` | Créer un commentaire sur une entité | VIEWER | +| `resolve_comment` | Marquer un commentaire comme résolu | GESTIONNAIRE | +| `get_unresolved_comments_count` | Nombre de commentaires non résolus | VIEWER | + +### 6.9 Tools — Custom Fields (splittés) + +| Tool | Description | Rôle min | +|---|---|---| +| `list_custom_field_values` | Lister les custom field values d'une entité | VIEWER | +| `upsert_custom_field_values` | Créer ou mettre à jour des custom field values | GESTIONNAIRE | +| `delete_custom_field_value` | Supprimer une custom field value | GESTIONNAIRE | + +### 6.10 Tools — Documents (splittés) + +| Tool | Description | Rôle min | +|---|---|---| +| `list_documents` | Lister les documents d'une entité | VIEWER | +| `delete_document` | Supprimer un document | GESTIONNAIRE | + +> **Limitation connue :** L'upload de documents n'est pas supporté via MCP. Le protocole MCP échange du JSON — l'upload de fichiers binaires (multipart/form-data) n'est pas compatible. Les uploads doivent se faire via l'API REST `/api/documents` (POST multipart). Cette limitation pourra être réévaluée si le protocole MCP ajoute un support binaire. + +### 6.11 Tools — Machine Links (splittés) + +| Tool | Description | Rôle min | +|---|---|---| +| `list_machine_links` | Lister les liens composant/pièce/produit d'une machine | VIEWER | +| `add_machine_links` | Ajouter des liens machine↔composant/pièce/produit | GESTIONNAIRE | +| `update_machine_link` | Modifier un lien (quantité, overrides) | GESTIONNAIRE | +| `remove_machine_link` | Supprimer un lien | GESTIONNAIRE | + +### 6.12 Tools — Slots + +| Tool | Description | Rôle min | +|---|---|---| +| `list_slots` | Lister les slots d'un composant ou pièce avec état (rempli/vide, requirement). Paramètre `entityType: "composant"\|"piece"` + `entityId` | VIEWER | +| `update_slots` | Remplir un ou plusieurs slots. Paramètre `slots: [{slotId, selectedPieceId?\|selectedProductId?\|selectedComposantId?}]` | GESTIONNAIRE | + +> **Note :** Un seul tool `list_slots` et un seul `update_slots` — ils acceptent un paramètre `entityType` pour dispatcher vers composant ou pièce. Un seul fichier d'implémentation par tool. + +### 6.13 Tools — ModelTypes + +| Tool | Description | Rôle min | +|---|---|---| +| `list_model_types` | Lister les ModelTypes par catégorie avec skeleton requirements | VIEWER | +| `get_model_type` | Détail complet d'un ModelType (requirements + custom fields) | VIEWER | +| `create_model_type` | Créer un ModelType | GESTIONNAIRE | +| `update_model_type` | Modifier un ModelType | GESTIONNAIRE | +| `delete_model_type` | Supprimer un ModelType | GESTIONNAIRE | + +**Total : ~55 tools** (splittés pour des schémas JSON non-ambigus, meilleure compatibilité LLM) + +> **Note :** Les tools d'administration des profils (`list_profiles`, `create_profile`, etc.) ne sont pas inclus — la gestion des profils reste exclusivement via l'API REST `/api/admin/profiles` (ROLE_ADMIN). Cela évite d'exposer la gestion des comptes/mots de passe via MCP. + +## 7. Resources MCP + +| URI | Description | Contenu | +|---|---|---| +| `inventory://schema/entities` | Schéma de toutes les entités | Nom, champs (nom, type, nullable, description) pour chaque entité | +| `inventory://model-types/{category}` | ModelTypes par catégorie | Liste des ModelTypes avec leurs skeleton requirements et custom fields | +| `inventory://roles` | Hiérarchie des rôles | Rôles et permissions associées pour guider le LLM | +| `inventory://stats` | Statistiques globales | Compteurs de chaque entité, commentaires ouverts | + +## 8. Workflows de création guidés + +### 8.1 Créer un Composant complet + +``` +1. list_model_types(category: "composant") + → Choisir le type de composant + +2. get_model_type(modelTypeId) + → Voir les skeleton requirements : pièces, produits, sous-composants attendus + → Voir les custom fields de chaque requirement + +3. create_composant(name, reference, modelTypeId, constructeurs) + → Reçoit: { id, slots: [{slotId, type, requirementName}, ...] } + +4. search_inventory(query: "Roulement", types: ["piece"]) + → Trouver les pièces candidates pour chaque slot + +5. update_slots([{slotId, selectedPieceId}, {slotId, selectedProductId}, ...]) + → Remplir les slots + +6. upsert_custom_field_values(entityType: "composant", entityId, + fields: [{name: "Tension", value: "220V"}, ...]) + → Remplir les custom fields +``` + +### 8.2 Créer une Pièce complète + +``` +1. list_model_types(category: "piece") +2. get_model_type(modelTypeId) +3. create_piece(name, reference, modelTypeId, constructeurs) + → Reçoit: { id, productSlots: [{slotId, requirementName}, ...] } +4. search_inventory(query: "...", types: ["product"]) +5. update_slots([{slotId, selectedProductId}, ...]) +6. upsert_custom_field_values(...) +``` + +### 8.3 Créer un Produit + +``` +1. list_model_types(category: "product") +2. create_product(name, reference, modelTypeId, prix, constructeurs) +3. upsert_custom_field_values(...) +``` + +### 8.4 Créer une Machine complète (de bas en haut) + +``` +1. Créer les produits nécessaires (§8.3) +2. Créer les pièces avec les produits dans les slots (§8.2) +3. Créer les composants avec les pièces dans les slots (§8.1) +4. list_sites → choisir le site +5. create_machine(name, reference, siteId, constructeurs) +6. add_machine_links(machineId, links: [ + {type: "composant", entityId, quantity}, + {type: "piece", entityId, quantity}, + {type: "product", entityId} + ]) +7. upsert_custom_field_values(entityType: "machine", machineId, ...) +``` + +## 9. Pagination + +Toutes les tools `list_*` utilisent un contrat de pagination uniforme : + +### Paramètres d'entrée + +| Paramètre | Type | Default | Description | +|---|---|---|---| +| `page` | int | 1 | Numéro de page (1-indexed) | +| `limit` | int | 30 | Nombre d'items par page (max 100) | + +### Format de réponse + +```json +{ + "items": [...], + "total": 142, + "page": 1, + "limit": 30, + "pageCount": 5 +} +``` + +## 10. Format des erreurs + +Toutes les erreurs MCP suivent un format uniforme via `isError: true` dans la réponse tool : + +```json +{ + "isError": true, + "content": [{"type": "text", "text": "Permission denied: ROLE_GESTIONNAIRE required for create_machine"}] +} +``` + +### Catégories d'erreurs + +| Code | Description | Exemple | +|---|---|---| +| `auth_error` | Credentials invalides ou manquants | "Authentication failed: invalid password" | +| `permission_denied` | Rôle insuffisant pour l'opération | "Permission denied: ROLE_GESTIONNAIRE required" | +| `not_found` | Entité introuvable | "Machine not found: cl4a8b..." | +| `validation_error` | Données invalides | "Validation failed: name is required" | +| `rate_limited` | Trop de tentatives d'auth échouées | "Rate limited: try again in 45 seconds" | +| `internal_error` | Erreur serveur inattendue | "Internal error: database connection failed" | + +Le champ `text` inclut toujours la catégorie en préfixe pour que le LLM puisse adapter son comportement. + +## 11. Configuration + +### 11.1 Symfony — config/packages/mcp.yaml + +```yaml +mcp: + app: 'inventory' + version: '%env(file:resolve:VERSION)%' + description: 'Inventory MCP Server - Gestion inventaire industriel (machines, pièces, composants, produits)' + instructions: | + Serveur MCP pour gérer un inventaire industriel. + Entités principales : Machine, Composant, Pièce, Produit, Site, Constructeur. + Utilisez search_inventory pour chercher dans toutes les entités. + Utilisez get_model_type pour comprendre la structure attendue avant de créer un composant ou une pièce. + Consultez la resource inventory://schema/entities pour voir le schéma complet. + Authentification requise : envoyez X-Profile-Id et X-Profile-Password dans les headers HTTP. + client_transports: + stdio: true + http: true + http: + path: /_mcp + session: + store: file + directory: '%kernel.cache_dir%/mcp-sessions' + ttl: 3600 +``` + +### 11.2 Security — config/packages/security.yaml (ajout firewall) + +```yaml +security: + firewalls: + # AVANT le firewall api existant + mcp: + pattern: ^/_mcp + stateless: true + custom_authenticators: + - App\Mcp\Security\McpHeaderAuthenticator + api: + pattern: ^/api + # ... existant ... +``` + +### 11.3 Rate Limiter — config/packages/rate_limiter.yaml + +```yaml +framework: + rate_limiter: + mcp_auth: + policy: sliding_window + limit: 5 + interval: '1 minute' +``` + +### 11.4 Routes — config/routes.yaml (ajout) + +```yaml +mcp: + resource: . + type: mcp +``` + +### 11.5 Logging — config/packages/monolog.yaml (ajout) + +```yaml +monolog: + channels: ['mcp'] + handlers: + mcp: + type: rotating_file + path: '%kernel.logs_dir%/mcp.log' + level: info + channels: ['mcp'] + max_files: 30 +``` + +## 12. Configuration des clients + +### 12.1 Claude Code (local, stdio via Docker) + +Fichier `.mcp.json` à la racine du projet : + +```json +{ + "mcpServers": { + "inventory": { + "command": "docker", + "args": [ + "exec", "-i", + "-e", "MCP_PROFILE_ID=", + "-e", "MCP_PROFILE_PASSWORD=", + "php-inventory-apache", + "php", "bin/console", "mcp:server" + ] + } + } +} +``` + +> **Note :** Les env vars sont passées via les flags `-e` de `docker exec` car le bloc `env` de `.mcp.json` ne les injecte pas dans le container Docker. Si PHP et les dépendances Composer sont disponibles directement sur l'hôte (hors Docker), on peut utiliser `"command": "php", "args": ["bin/console", "mcp:server"]` avec un bloc `env` standard. + +### 12.2 Claude Desktop (distant, HTTP via tunnel) + +Fichier `claude_desktop_config.json` : + +```json +{ + "mcpServers": { + "inventory": { + "url": "https://inventory.company-tunnel.com/_mcp", + "headers": { + "X-Profile-Id": "", + "X-Profile-Password": "" + } + } + } +} +``` + +### 12.3 ChatGPT Desktop (HTTP via tunnel) + +Même principe HTTP : URL du tunnel + headers d'auth. Format de config selon la doc ChatGPT MCP. + +### 12.4 Codex (HTTP via tunnel) + +Même config HTTP que Claude Desktop. + +## 13. Structure des fichiers + +``` +src/ +└── Mcp/ + ├── Tool/ + │ ├── SearchInventoryTool.php # search_inventory + │ ├── DashboardStatsTool.php # get_dashboard_stats + │ ├── ActivityLogTool.php # get_activity_log + │ ├── EntityHistoryTool.php # get_entity_history + │ ├── Machine/ + │ │ ├── ListMachinesTool.php # list_machines + │ │ ├── GetMachineTool.php # get_machine + │ │ ├── CreateMachineTool.php # create_machine + │ │ ├── UpdateMachineTool.php # update_machine + │ │ ├── DeleteMachineTool.php # delete_machine + │ │ ├── MachineStructureTool.php # get_machine_structure + │ │ ├── CloneMachineTool.php # clone_machine + │ │ ├── ListMachineLinksTool.php # list_machine_links + │ │ ├── AddMachineLinksTool.php # add_machine_links + │ │ ├── UpdateMachineLinkTool.php # update_machine_link + │ │ └── RemoveMachineLinkTool.php # remove_machine_link + │ ├── Composant/ + │ │ ├── ListComposantsTool.php # list_composants + │ │ ├── GetComposantTool.php # get_composant + │ │ ├── CreateComposantTool.php # create_composant + │ │ ├── UpdateComposantTool.php # update_composant + │ │ └── DeleteComposantTool.php # delete_composant + │ ├── Piece/ + │ │ ├── ListPiecesTool.php # list_pieces + │ │ ├── GetPieceTool.php # get_piece + │ │ ├── CreatePieceTool.php # create_piece + │ │ ├── UpdatePieceTool.php # update_piece + │ │ └── DeletePieceTool.php # delete_piece + │ ├── Slot/ + │ │ ├── ListSlotsTool.php # list_slots (dispatche par entityType) + │ │ └── UpdateSlotsTool.php # update_slots + │ ├── Product/ + │ │ ├── ListProductsTool.php # list_products + │ │ ├── GetProductTool.php # get_product + │ │ ├── CreateProductTool.php # create_product + │ │ ├── UpdateProductTool.php # update_product + │ │ └── DeleteProductTool.php # delete_product + │ ├── Site/ + │ │ ├── ListSitesTool.php # list_sites + │ │ ├── GetSiteTool.php # get_site + │ │ ├── CreateSiteTool.php # create_site + │ │ ├── UpdateSiteTool.php # update_site + │ │ └── DeleteSiteTool.php # delete_site + │ ├── Constructeur/ + │ │ ├── ListConstructeursTool.php # list_constructeurs + │ │ ├── GetConstructeurTool.php # get_constructeur + │ │ ├── CreateConstructeurTool.php # create_constructeur + │ │ ├── UpdateConstructeurTool.php # update_constructeur + │ │ └── DeleteConstructeurTool.php # delete_constructeur + │ ├── ModelType/ + │ │ ├── ListModelTypesTool.php # list_model_types + │ │ ├── GetModelTypeTool.php # get_model_type + │ │ ├── CreateModelTypeTool.php # create_model_type + │ │ ├── UpdateModelTypeTool.php # update_model_type + │ │ ├── DeleteModelTypeTool.php # delete_model_type + │ │ └── SyncModelTypeTool.php # sync_model_type + │ ├── CustomField/ + │ │ ├── ListCustomFieldValuesTool.php # list_custom_field_values + │ │ ├── UpsertCustomFieldValuesTool.php # upsert_custom_field_values + │ │ └── DeleteCustomFieldValueTool.php # delete_custom_field_value + │ ├── Document/ + │ │ ├── ListDocumentsTool.php # list_documents + │ │ └── DeleteDocumentTool.php # delete_document + │ └── Comment/ + │ ├── ListCommentsTool.php # list_comments + │ ├── CreateCommentTool.php # create_comment + │ ├── ResolveCommentTool.php # resolve_comment + │ └── UnresolvedCountTool.php # get_unresolved_comments_count + ├── Resource/ + │ ├── SchemaResource.php # inventory://schema/entities + │ ├── ModelTypesResource.php # inventory://model-types/{category} + │ ├── RolesResource.php # inventory://roles + │ └── StatsResource.php # inventory://stats + └── Security/ + └── McpHeaderAuthenticator.php # Symfony Authenticator pour firewall MCP + +docs/ +└── mcp/ + └── README.md # Guide utilisateur complet +``` + +## 14. Documentation utilisateur (docs/mcp/README.md) + +Le guide contiendra : + +1. **Introduction** — Qu'est-ce que le MCP Inventory, à quoi ça sert, quels clients sont supportés +2. **Prérequis** — Profil avec rôle suffisant, accès au tunnel, client MCP compatible +3. **Installation & configuration par client** — Exemples copier-coller pour : + - Claude Code (stdio via Docker) + - Claude Desktop (HTTP via tunnel) + - ChatGPT Desktop (HTTP via tunnel) + - Codex (HTTP via tunnel) +4. **Catalogue des tools** — Tableau complet avec nom, description, paramètres, rôle requis +5. **Workflows guidés** — Comment créer une machine, un composant, une pièce, un produit (étape par étape avec exemples d'appels) +6. **Resources disponibles** — URIs et contenu exposé +7. **Rôles & permissions** — Quel rôle permet quelles actions +8. **Format des erreurs** — Catégories et exemples +9. **Limitations connues** — Upload documents non supporté via MCP +10. **Troubleshooting** — Erreurs courantes (auth failed, tunnel down, rôle insuffisant, rate limited) + +## 15. Sécurité + +| Mesure | Détail | +|---|---| +| **Firewall Symfony** | `/_mcp` a son propre firewall avec `McpHeaderAuthenticator` — intégré au système de sécurité standard | +| **Vérification rôle** | Chaque tool vérifie via `$security->isGranted()` avec hiérarchie des rôles | +| **Audit trail** | `AbstractAuditSubscriber.resolveActorProfileId()` fonctionne nativement car `$security->getUser()` retourne le Profile authentifié | +| **Rate limiting** | 5 tentatives d'auth échouées par minute par IP → rejet | +| **Transport chiffré** | Le tunnel assure le chiffrement en transit pour les clients distants | +| **Pas de secrets dans le code** | Credentials dans env vars (stdio) ou headers (HTTP), jamais en dur | +| **Sessions MCP** | TTL 1h, stockage fichier, nettoyage automatique | +| **CORS** | Non nécessaire — les clients MCP sont des apps natives (pas des navigateurs). Le tunnel termine la connexion côté serveur. À réévaluer si un client browser-based apparaît | + +## 16. Backward Compatibility + +Les tools MCP suivent une politique additive : +- **Ajouts** : nouveaux tools, nouveaux paramètres optionnels → toujours OK +- **Suppressions** : marquer un tool comme deprecated pendant 1 version avant suppression +- **Breaking changes** : changer le type/nom d'un paramètre requis → bumper la version MCP + +Le champ `version` dans la config MCP (lu depuis `VERSION`) signale les changements. + +## 17. Dépendances à installer + +```bash +composer require symfony/mcp-bundle symfony/rate-limiter +``` + +Le bundle tire `mcp/sdk` automatiquement. + +## 18. Tests + +Les tools MCP seront testés via : + +- **Tests unitaires** : chaque tool testé avec des mocks de repositories, vérification des paramètres et des réponses +- **Tests d'intégration** : appels MCP stdio via `docker exec ... php bin/console mcp:server` avec des fixtures +- **Tests de sécurité** : vérification que les tools rejettent les appels sans auth, avec rôle insuffisant, et après rate limiting +- Pattern : hériter de `AbstractApiTestCase` pour réutiliser les factories existantes (`createProfile()`, `createMachine()`, etc.) + +## 19. Spike / PoC initial + +Avant l'implémentation complète, une étape de validation : + +1. Installer `symfony/mcp-bundle` dans le projet +2. Créer un tool minimal (`get_dashboard_stats`) avec l'attribut `#[McpTool]` +3. Tester le transport stdio : `docker exec -i php-inventory-apache php bin/console mcp:server` +4. Tester le transport HTTP : appel POST sur `/_mcp` +5. Valider que l'authenticator custom fonctionne avec le firewall +6. Confirmer que `$security->getUser()` retourne le bon Profile dans un tool + +Si le PoC révèle des incompatibilités avec l'API du bundle, adapter le design avant de continuer. diff --git a/src/Mcp/Resource/RolesResource.php b/src/Mcp/Resource/RolesResource.php new file mode 100644 index 0000000..95a2874 --- /dev/null +++ b/src/Mcp/Resource/RolesResource.php @@ -0,0 +1,35 @@ + [ + 'ROLE_ADMIN' => 'Inherits ROLE_GESTIONNAIRE. Can manage profiles.', + 'ROLE_GESTIONNAIRE' => 'Inherits ROLE_VIEWER. Can create, update, delete all entities.', + 'ROLE_VIEWER' => 'Inherits ROLE_USER. Can read all entities, create comments, search.', + 'ROLE_USER' => 'Base role. Authenticated but minimal access.', + ], + 'tool_permissions' => [ + 'ROLE_VIEWER' => 'list_*, get_*, search_inventory, get_dashboard_stats, get_entity_history, get_activity_log, list_comments, create_comment, get_unresolved_comments_count, list_custom_field_values, list_documents, list_slots', + 'ROLE_GESTIONNAIRE' => 'All VIEWER tools + create_*, update_*, delete_*, clone_machine, update_slots, add_machine_links, remove_machine_link, resolve_comment, upsert_custom_field_values, sync_model_type', + ], + ]; + + return [new TextContent(text: json_encode($roles, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE))]; + } +} diff --git a/src/Mcp/Resource/SchemaResource.php b/src/Mcp/Resource/SchemaResource.php new file mode 100644 index 0000000..3842c77 --- /dev/null +++ b/src/Mcp/Resource/SchemaResource.php @@ -0,0 +1,53 @@ + [ + 'fields' => ['id (string)', 'name (string, unique)', 'reference (string?)', 'prix (string?)', 'createdAt', 'updatedAt'], + 'relationships' => ['site (Site, required)', 'constructeurs (Constructeur[])', 'componentLinks (MachineComponentLink[])', 'pieceLinks (MachinePieceLink[])', 'productLinks (MachineProductLink[])', 'customFields (CustomField[])', 'customFieldValues (CustomFieldValue[])'], + ], + 'Composant' => [ + 'fields' => ['id (string)', 'name (string, unique)', 'reference (string?)', 'description (text?)', 'prix (string?)', 'createdAt', 'updatedAt'], + 'relationships' => ['typeComposant (ModelType?)', 'constructeurs (Constructeur[])', 'pieceSlots (ComposantPieceSlot[])', 'productSlots (ComposantProductSlot[])', 'subcomponentSlots (ComposantSubcomponentSlot[])', 'customFieldValues (CustomFieldValue[])'], + ], + 'Piece' => [ + 'fields' => ['id (string)', 'name (string)', 'reference (string?, unique)', 'description (text?)', 'prix (string?)', 'createdAt', 'updatedAt'], + 'relationships' => ['typePiece (ModelType?)', 'product (Product?)', 'constructeurs (Constructeur[])', 'productSlots (PieceProductSlot[])', 'customFieldValues (CustomFieldValue[])'], + ], + 'Product' => [ + 'fields' => ['id (string)', 'name (string, unique)', 'reference (string?)', 'supplierPrice (string?)', 'createdAt', 'updatedAt'], + 'relationships' => ['typeProduct (ModelType?)', 'constructeurs (Constructeur[])'], + ], + 'Site' => [ + 'fields' => ['id (string)', 'name (string)', 'contactName (string)', 'contactPhone (string)', 'contactAddress (string)', 'contactPostalCode (string)', 'contactCity (string)', 'color (string)', 'createdAt', 'updatedAt'], + 'relationships' => ['machines (Machine[])'], + ], + 'Constructeur' => [ + 'fields' => ['id (string)', 'name (string, unique)', 'email (string?)', 'phone (string?)', 'createdAt', 'updatedAt'], + 'relationships' => ['machines (Machine[])', 'composants (Composant[])', 'pieces (Piece[])', 'products (Product[])'], + ], + 'ModelType' => [ + 'fields' => ['id (string)', 'name (string)', 'category (machine|composant|piece|product)', 'code (string?)', 'createdAt', 'updatedAt'], + 'relationships' => ['skeletonPieceRequirements[]', 'skeletonProductRequirements[]', 'skeletonSubcomponentRequirements[]'], + ], + ]; + + return [new TextContent(text: json_encode($schema, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE))]; + } +} diff --git a/src/Mcp/Resource/StatsResource.php b/src/Mcp/Resource/StatsResource.php new file mode 100644 index 0000000..c934d73 --- /dev/null +++ b/src/Mcp/Resource/StatsResource.php @@ -0,0 +1,48 @@ +em->createQuery( + "SELECT COUNT(c.id) FROM App\\Entity\\Comment c WHERE c.status = 'open'" + )->getSingleScalarResult(); + + return [new TextContent(text: json_encode([ + 'machines' => $this->machines->count([]), + 'pieces' => $this->pieces->count([]), + 'composants' => $this->composants->count([]), + 'products' => $this->products->count([]), + 'sites' => $this->sites->count([]), + 'unresolvedComments' => $unresolvedComments, + ], JSON_THROW_ON_ERROR))]; + } +}