- 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>
1473 lines
48 KiB
Markdown
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"
|
|
```
|