From 2b9095b1a2d1734c39bc74ba85e7ea648524e548 Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 19:22:42 +0100 Subject: [PATCH] docs : add MCP server implementation plan Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-03-15-mcp-server.md | 2100 +++++++++++++++++ 1 file changed, 2100 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-15-mcp-server.md diff --git a/docs/superpowers/plans/2026-03-15-mcp-server.md b/docs/superpowers/plans/2026-03-15-mcp-server.md new file mode 100644 index 0000000..2c65edd --- /dev/null +++ b/docs/superpowers/plans/2026-03-15-mcp-server.md @@ -0,0 +1,2100 @@ +# 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 an MCP server to Lesstime exposing projects, tasks, and time tracking for AI clients via STDIO and HTTP transports. + +**Architecture:** Install `symfony/mcp-bundle`, create 21 tool classes in `src/Mcp/Tool/` organized by domain (Project, Task, TaskMeta, TimeEntry, Reference). HTTP transport secured by API token on User entity with a custom Symfony authenticator. STDIO for local Claude Code usage. + +**Tech Stack:** symfony/mcp-bundle, Symfony 8 security (custom authenticator), Doctrine ORM, PHP 8.4 + +**Spec:** `docs/superpowers/specs/2026-03-15-mcp-server-design.md` + +--- + +## Chunk 1: Infrastructure (Bundle, Auth, Config) + +### Task 1: Install symfony/mcp-bundle + +**Files:** +- Modify: `composer.json` +- Create: `config/packages/mcp.yaml` +- Create: `config/routes/mcp.yaml` + +- [ ] **Step 1: Install the bundle via Composer** + +Run inside Docker container: +```bash +docker exec -u www-data php-lesstime-fpm composer require symfony/mcp-bundle +``` + +- [ ] **Step 2: Create MCP config** + +Create `config/packages/mcp.yaml`: +```yaml +mcp: + app: 'lesstime' + version: '1.0.0' + description: 'Lesstime project management — projects, tasks, time tracking' + instructions: | + This server provides access to the Lesstime project management system. + You can list/create/update/delete projects, tasks, and time entries. + Tasks belong to projects and have statuses, priorities, efforts, tags, and groups. + Statuses, priorities, efforts, and tags are GLOBAL (shared across all projects). + Groups are PER-PROJECT (each group belongs to one project). + Time entries track work duration and can be linked to projects and tasks. + Use list-statuses, list-priorities, list-efforts, list-tags, list-groups to discover + available metadata before creating or updating tasks. + Use list-users and list-clients to discover valid user and client IDs. + client_transports: + stdio: true + http: true + http: + path: /_mcp + session: + store: file + directory: '%kernel.cache_dir%/mcp-sessions' + ttl: 3600 +``` + +- [ ] **Step 3: Create MCP route config** + +Create `config/routes/mcp.yaml`: +```yaml +mcp: + resource: . + type: mcp +``` + +- [ ] **Step 4: Verify the bundle is loaded** + +```bash +docker exec -u www-data php-lesstime-fpm php bin/console mcp:server --help +``` +Expected: Help output for the `mcp:server` command (no errors). + +- [ ] **Step 5: Commit** + +```bash +git add composer.json composer.lock symfony.lock config/packages/mcp.yaml config/routes/mcp.yaml +git commit -m "feat : install symfony/mcp-bundle with STDIO + HTTP transport config" +``` + +--- + +### Task 2: Add API token to User entity + +**Files:** +- Modify: `src/Entity/User.php` +- Create: new Doctrine migration + +- [ ] **Step 1: Add apiToken property to User entity** + +In `src/Entity/User.php`, add after the `$createdAt` property: + +```php +#[ORM\Column(length: 64, unique: true, nullable: true)] +private ?string $apiToken = null; +``` + +Add getter and setter after the `eraseCredentials` method: + +```php +public function getApiToken(): ?string +{ + return $this->apiToken; +} + +public function setApiToken(?string $apiToken): static +{ + $this->apiToken = $apiToken; + + return $this; +} +``` + +- [ ] **Step 2: Generate and run the migration** + +```bash +docker exec -u www-data php-lesstime-fpm php bin/console doctrine:migrations:diff +docker exec -u www-data php-lesstime-fpm php bin/console doctrine:migrations:migrate --no-interaction +``` + +Expected: Migration runs successfully, adds `api_token` column to `user` table. + +- [ ] **Step 3: Commit** + +```bash +git add src/Entity/User.php migrations/ +git commit -m "feat : add apiToken field to User entity for MCP HTTP auth" +``` + +--- + +### Task 3: Create API token authenticator + +**Files:** +- Create: `src/Security/ApiTokenAuthenticator.php` +- Modify: `config/packages/security.yaml` + +- [ ] **Step 1: Create the authenticator class** + +Create `src/Security/ApiTokenAuthenticator.php`: + +```php +headers->has('Authorization') + && str_starts_with((string) $request->headers->get('Authorization'), 'Bearer '); + } + + public function authenticate(Request $request): Passport + { + $authHeader = (string) $request->headers->get('Authorization'); + $token = substr($authHeader, 7); // Remove "Bearer " prefix + + if ('' === $token) { + throw new CustomUserMessageAuthenticationException('API token missing.'); + } + + return new SelfValidatingPassport( + new UserBadge($token, function (string $token): ?\App\Entity\User { + $user = $this->userRepository->findOneBy(['apiToken' => $token]); + + if (null === $user) { + throw new CustomUserMessageAuthenticationException('Invalid API token.'); + } + + return $user; + }) + ); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + return null; // Let the request continue + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response + { + return new JsonResponse( + ['error' => $exception->getMessageKey()], + Response::HTTP_UNAUTHORIZED + ); + } +} +``` + +- [ ] **Step 2: Add MCP firewall to security.yaml** + +In `config/packages/security.yaml`, add the `mcp` firewall **before** the `api` firewall: + +```yaml + mcp: + pattern: ^/_mcp + stateless: true + provider: app_user_provider + custom_authenticators: + - App\Security\ApiTokenAuthenticator +``` + +Add access control rule **before** the existing `/api` rules: + +```yaml + - { path: ^/_mcp, roles: IS_AUTHENTICATED_FULLY } +``` + +- [ ] **Step 3: Verify the firewall is registered** + +```bash +docker exec -u www-data php-lesstime-fpm php bin/console debug:firewall +``` + +Expected: `mcp` firewall appears in the list. + +- [ ] **Step 4: Commit** + +```bash +git add src/Security/ApiTokenAuthenticator.php config/packages/security.yaml +git commit -m "feat : add ApiTokenAuthenticator for MCP HTTP transport" +``` + +--- + +### Task 4: Create generate-api-token command + +**Files:** +- Create: `src/Command/GenerateApiTokenCommand.php` + +- [ ] **Step 1: Create the console command** + +Create `src/Command/GenerateApiTokenCommand.php`: + +```php +addArgument('username', InputArgument::REQUIRED, 'The username to generate a token for'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $username = $input->getArgument('username'); + + $user = $this->userRepository->findOneBy(['username' => $username]); + + if (null === $user) { + $io->error(\sprintf('User "%s" not found.', $username)); + + return Command::FAILURE; + } + + $token = bin2hex(random_bytes(32)); + $user->setApiToken($token); + $this->entityManager->flush(); + + $io->success(\sprintf('API token generated for user "%s":', $username)); + $io->writeln($token); + + return Command::SUCCESS; + } +} +``` + +- [ ] **Step 2: Verify the command works** + +```bash +docker exec -u www-data php-lesstime-fpm php bin/console app:generate-api-token admin +``` + +Expected: Outputs a 64-character hex token. + +- [ ] **Step 3: Commit** + +```bash +git add src/Command/GenerateApiTokenCommand.php +git commit -m "feat : add app:generate-api-token console command" +``` + +--- + +### Task 5: Add Nginx location and update fixtures + +**Files:** +- Modify: `docker/nginx/conf.d/lesstime.conf` +- Modify: `src/DataFixtures/AppFixtures.php` + +- [ ] **Step 1: Add Nginx location block for /_mcp** + +In `docker/nginx/conf.d/lesstime.conf`, add this block **before** the `location ^~ /api/` block: + +```nginx + location ^~ /_mcp { + root /var/www/html/public; + try_files $uri /index.php?$query_string; + } +``` + +- [ ] **Step 2: Add API token to admin fixture** + +In `src/DataFixtures/AppFixtures.php`, in the section where the admin user is created, add after `setPassword(...)`: + +```php +->setApiToken('dev-mcp-token-for-testing-only-do-not-use-in-production') +``` + +- [ ] **Step 3: Restart Nginx and reload fixtures** + +```bash +docker restart nginx-lesstime +docker exec -u www-data php-lesstime-fpm php bin/console doctrine:fixtures:load --no-interaction +``` + +- [ ] **Step 4: Test HTTP transport with curl** + +```bash +curl -s -X POST http://localhost:8082/_mcp \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer dev-mcp-token-for-testing-only-do-not-use-in-production" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' +``` + +Expected: JSON-RPC response with server capabilities (or at least not a 401/404). + +- [ ] **Step 5: Test STDIO transport** + +```bash +echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' | docker exec -i php-lesstime-fpm php bin/console mcp:server +``` + +Expected: JSON-RPC response via stdout. + +- [ ] **Step 6: Commit** + +```bash +git add docker/nginx/conf.d/lesstime.conf src/DataFixtures/AppFixtures.php +git commit -m "feat : add Nginx /_mcp location and API token fixture for MCP" +``` + +--- + +## Chunk 2: Reference & Project Tools + +### Task 6: Reference tools (list-users, list-clients) + +**Files:** +- Create: `src/Mcp/Tool/Reference/ListUsersTool.php` +- Create: `src/Mcp/Tool/Reference/ListClientsTool.php` + +- [ ] **Step 1: Create ListUsersTool** + +Create `src/Mcp/Tool/Reference/ListUsersTool.php`: + +```php +userRepository->findBy([], ['username' => 'ASC']); + + return json_encode(array_map(fn($user) => [ + 'id' => $user->getId(), + 'username' => $user->getUsername(), + ], $users)); + } +} +``` + +- [ ] **Step 2: Create ListClientsTool** + +Create `src/Mcp/Tool/Reference/ListClientsTool.php`: + +```php +clientRepository->findBy([], ['name' => 'ASC']); + + return json_encode(array_map(fn($client) => [ + 'id' => $client->getId(), + 'name' => $client->getName(), + 'email' => $client->getEmail(), + ], $clients)); + } +} +``` + +- [ ] **Step 3: Verify tools are discovered** + +```bash +docker exec -u www-data php-lesstime-fpm php bin/console debug:container --tag=mcp.tool 2>/dev/null || docker exec -u www-data php-lesstime-fpm php bin/console mcp:server --help +``` + +Check that both tools appear in the registered MCP tools. + +- [ ] **Step 4: Commit** + +```bash +git add src/Mcp/Tool/Reference/ +git commit -m "feat : add list-users and list-clients MCP tools" +``` + +--- + +### Task 7: Project tools (list, get, create, update) + +**Files:** +- Create: `src/Mcp/Tool/Project/ListProjectsTool.php` +- Create: `src/Mcp/Tool/Project/GetProjectTool.php` +- Create: `src/Mcp/Tool/Project/CreateProjectTool.php` +- Create: `src/Mcp/Tool/Project/UpdateProjectTool.php` + +- [ ] **Step 1: Create ListProjectsTool** + +Create `src/Mcp/Tool/Project/ListProjectsTool.php`: + +```php +projectRepository->findBy(['archived' => $archived], ['name' => 'ASC']); + + return json_encode(array_map(fn($project) => [ + 'id' => $project->getId(), + 'code' => $project->getCode(), + 'name' => $project->getName(), + 'description' => $project->getDescription(), + 'color' => $project->getColor(), + 'client' => $project->getClient() ? [ + 'id' => $project->getClient()->getId(), + 'name' => $project->getClient()->getName(), + ] : null, + 'archived' => $project->isArchived(), + ], $projects)); + } +} +``` + +- [ ] **Step 2: Create GetProjectTool** + +Create `src/Mcp/Tool/Project/GetProjectTool.php`: + +```php +projectRepository->find($id); + + if (null === $project) { + throw new \InvalidArgumentException(\sprintf('Project with ID %d not found.', $id)); + } + + // Count tasks per status + $qb = $this->taskRepository->createQueryBuilder('t') + ->select('s.label AS statusLabel, COUNT(t.id) AS taskCount') + ->leftJoin('t.status', 's') + ->where('t.project = :project') + ->setParameter('project', $project) + ->groupBy('s.id, s.label'); + + $statusCounts = []; + $totalTasks = 0; + foreach ($qb->getQuery()->getResult() as $row) { + $label = $row['statusLabel'] ?? 'No status'; + $count = (int) $row['taskCount']; + $statusCounts[$label] = $count; + $totalTasks += $count; + } + + return json_encode([ + 'id' => $project->getId(), + 'code' => $project->getCode(), + 'name' => $project->getName(), + 'description' => $project->getDescription(), + 'color' => $project->getColor(), + 'client' => $project->getClient() ? [ + 'id' => $project->getClient()->getId(), + 'name' => $project->getClient()->getName(), + ] : null, + 'archived' => $project->isArchived(), + 'taskSummary' => $statusCounts, + 'totalTasks' => $totalTasks, + ]); + } +} +``` + +- [ ] **Step 3: Create CreateProjectTool** + +Create `src/Mcp/Tool/Project/CreateProjectTool.php`: + +```php +setName($name); + $project->setCode($code); + + if (null !== $description) { + $project->setDescription($description); + } + if (null !== $color) { + $project->setColor($color); + } + if (null !== $clientId) { + $client = $this->clientRepository->find($clientId); + if (null === $client) { + throw new \InvalidArgumentException(\sprintf('Client with ID %d not found.', $clientId)); + } + $project->setClient($client); + } + + $this->entityManager->persist($project); + $this->entityManager->flush(); + + return json_encode([ + 'id' => $project->getId(), + 'code' => $project->getCode(), + 'name' => $project->getName(), + 'description' => $project->getDescription(), + 'color' => $project->getColor(), + 'client' => $project->getClient() ? [ + 'id' => $project->getClient()->getId(), + 'name' => $project->getClient()->getName(), + ] : null, + 'archived' => $project->isArchived(), + ]); + } +} +``` + +- [ ] **Step 4: Create UpdateProjectTool** + +Create `src/Mcp/Tool/Project/UpdateProjectTool.php`: + +```php +projectRepository->find($id); + + if (null === $project) { + throw new \InvalidArgumentException(\sprintf('Project with ID %d not found.', $id)); + } + + if (null !== $name) { + $project->setName($name); + } + if (null !== $code) { + $project->setCode($code); + } + if (null !== $description) { + $project->setDescription($description); + } + if (null !== $color) { + $project->setColor($color); + } + if (null !== $clientId) { + $client = $this->clientRepository->find($clientId); + if (null === $client) { + throw new \InvalidArgumentException(\sprintf('Client with ID %d not found.', $clientId)); + } + $project->setClient($client); + } + if (null !== $archived) { + $project->setArchived($archived); + } + + $this->entityManager->flush(); + + return json_encode([ + 'id' => $project->getId(), + 'code' => $project->getCode(), + 'name' => $project->getName(), + 'description' => $project->getDescription(), + 'color' => $project->getColor(), + 'client' => $project->getClient() ? [ + 'id' => $project->getClient()->getId(), + 'name' => $project->getClient()->getName(), + ] : null, + 'archived' => $project->isArchived(), + ]); + } +} +``` + +- [ ] **Step 5: Test with STDIO** + +```bash +echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | docker exec -i php-lesstime-fpm php bin/console mcp:server +``` + +Expected: JSON response listing all registered tools including `list-projects`, `get-project`, `create-project`, `update-project`. + +- [ ] **Step 6: Commit** + +```bash +git add src/Mcp/Tool/Project/ src/Mcp/Tool/Reference/ +git commit -m "feat : add project and reference MCP tools (list/get/create/update)" +``` + +--- + +## Chunk 3: Task Tools + +### Task 8: List and get task tools + +**Files:** +- Create: `src/Mcp/Tool/Task/ListTasksTool.php` +- Create: `src/Mcp/Tool/Task/GetTaskTool.php` + +- [ ] **Step 1: Create ListTasksTool** + +Create `src/Mcp/Tool/Task/ListTasksTool.php`: + +```php +taskRepository->createQueryBuilder('t') + ->leftJoin('t.status', 's')->addSelect('s') + ->leftJoin('t.priority', 'p')->addSelect('p') + ->leftJoin('t.assignee', 'a')->addSelect('a') + ->leftJoin('t.project', 'pr')->addSelect('pr') + ->leftJoin('t.effort', 'e')->addSelect('e') + ->leftJoin('t.group', 'g')->addSelect('g') + ->leftJoin('t.tags', 'tg')->addSelect('tg') + ->where('t.archived = :archived') + ->setParameter('archived', $archived) + ->orderBy('t.id', 'DESC') + ->setMaxResults($limit); + + if (null !== $projectId) { + $qb->andWhere('pr.id = :projectId')->setParameter('projectId', $projectId); + } + if (null !== $statusId) { + $qb->andWhere('s.id = :statusId')->setParameter('statusId', $statusId); + } + if (null !== $assigneeId) { + $qb->andWhere('a.id = :assigneeId')->setParameter('assigneeId', $assigneeId); + } + if (null !== $priorityId) { + $qb->andWhere('p.id = :priorityId')->setParameter('priorityId', $priorityId); + } + if (null !== $groupId) { + $qb->andWhere('t.group = :groupId')->setParameter('groupId', $groupId); + } + + $tasks = $qb->getQuery()->getResult(); + + if (null !== $tagIds) { + $tasks = array_filter($tasks, function ($task) use ($tagIds) { + $taskTagIds = $task->getTags()->map(fn($t) => $t->getId())->toArray(); + + return !empty(array_intersect($tagIds, $taskTagIds)); + }); + } + + return json_encode(array_map(fn($task) => [ + 'id' => $task->getId(), + 'number' => $task->getNumber(), + 'title' => $task->getTitle(), + 'status' => $task->getStatus() ? [ + 'id' => $task->getStatus()->getId(), + 'label' => $task->getStatus()->getLabel(), + 'color' => $task->getStatus()->getColor(), + ] : null, + 'priority' => $task->getPriority() ? [ + 'id' => $task->getPriority()->getId(), + 'label' => $task->getPriority()->getLabel(), + 'color' => $task->getPriority()->getColor(), + ] : null, + 'assignee' => $task->getAssignee() ? [ + 'id' => $task->getAssignee()->getId(), + 'username' => $task->getAssignee()->getUsername(), + ] : null, + 'effort' => $task->getEffort() ? [ + 'id' => $task->getEffort()->getId(), + 'label' => $task->getEffort()->getLabel(), + ] : null, + 'group' => $task->getGroup() ? [ + 'id' => $task->getGroup()->getId(), + 'title' => $task->getGroup()->getTitle(), + ] : null, + 'project' => [ + 'id' => $task->getProject()->getId(), + 'code' => $task->getProject()->getCode(), + 'name' => $task->getProject()->getName(), + ], + 'tags' => $task->getTags()->map(fn($t) => [ + 'id' => $t->getId(), + 'label' => $t->getLabel(), + ])->toArray(), + 'archived' => $task->isArchived(), + ], array_values($tasks))); + } +} +``` + +- [ ] **Step 2: Create GetTaskTool** + +Create `src/Mcp/Tool/Task/GetTaskTool.php`: + +```php +taskRepository->find($id); + + if (null === $task) { + throw new \InvalidArgumentException(\sprintf('Task with ID %d not found.', $id)); + } + + return json_encode([ + 'id' => $task->getId(), + 'number' => $task->getNumber(), + 'title' => $task->getTitle(), + 'description' => $task->getDescription(), + 'status' => $task->getStatus() ? [ + 'id' => $task->getStatus()->getId(), + 'label' => $task->getStatus()->getLabel(), + 'color' => $task->getStatus()->getColor(), + 'isFinal' => $task->getStatus()->isFinal(), + ] : null, + 'priority' => $task->getPriority() ? [ + 'id' => $task->getPriority()->getId(), + 'label' => $task->getPriority()->getLabel(), + 'color' => $task->getPriority()->getColor(), + ] : null, + 'effort' => $task->getEffort() ? [ + 'id' => $task->getEffort()->getId(), + 'label' => $task->getEffort()->getLabel(), + ] : null, + 'assignee' => $task->getAssignee() ? [ + 'id' => $task->getAssignee()->getId(), + 'username' => $task->getAssignee()->getUsername(), + ] : null, + 'group' => $task->getGroup() ? [ + 'id' => $task->getGroup()->getId(), + 'title' => $task->getGroup()->getTitle(), + 'color' => $task->getGroup()->getColor(), + ] : null, + 'project' => [ + 'id' => $task->getProject()->getId(), + 'code' => $task->getProject()->getCode(), + 'name' => $task->getProject()->getName(), + ], + 'tags' => $task->getTags()->map(fn($t) => [ + 'id' => $t->getId(), + 'label' => $t->getLabel(), + 'color' => $t->getColor(), + ])->toArray(), + 'documents' => $task->getDocuments()->map(fn($doc) => [ + 'id' => $doc->getId(), + 'originalName' => $doc->getOriginalName(), + 'mimeType' => $doc->getMimeType(), + 'size' => $doc->getSize(), + 'createdAt' => $doc->getCreatedAt()?->format('c'), + 'uploadedBy' => $doc->getUploadedBy() ? [ + 'id' => $doc->getUploadedBy()->getId(), + 'username' => $doc->getUploadedBy()->getUsername(), + ] : null, + ])->toArray(), + 'archived' => $task->isArchived(), + ]); + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/Mcp/Tool/Task/ListTasksTool.php src/Mcp/Tool/Task/GetTaskTool.php +git commit -m "feat : add list-tasks and get-task MCP tools" +``` + +--- + +### Task 9: Create, update, delete task tools + +**Files:** +- Create: `src/Mcp/Tool/Task/CreateTaskTool.php` +- Create: `src/Mcp/Tool/Task/UpdateTaskTool.php` +- Create: `src/Mcp/Tool/Task/DeleteTaskTool.php` + +- [ ] **Step 1: Create CreateTaskTool** + +Create `src/Mcp/Tool/Task/CreateTaskTool.php`: + +```php +projectRepository->find($projectId); + if (null === $project) { + throw new \InvalidArgumentException(\sprintf('Project with ID %d not found.', $projectId)); + } + + $task = new Task(); + $task->setProject($project); + $task->setTitle($title); + $task->setNumber($this->taskRepository->findMaxNumberByProject($project) + 1); + + if (null !== $description) { + $task->setDescription($description); + } + if (null !== $statusId) { + $status = $this->taskStatusRepository->find($statusId); + if (null === $status) { + throw new \InvalidArgumentException(\sprintf('TaskStatus with ID %d not found.', $statusId)); + } + $task->setStatus($status); + } + if (null !== $priorityId) { + $priority = $this->taskPriorityRepository->find($priorityId); + if (null === $priority) { + throw new \InvalidArgumentException(\sprintf('TaskPriority with ID %d not found.', $priorityId)); + } + $task->setPriority($priority); + } + if (null !== $effortId) { + $effort = $this->taskEffortRepository->find($effortId); + if (null === $effort) { + throw new \InvalidArgumentException(\sprintf('TaskEffort with ID %d not found.', $effortId)); + } + $task->setEffort($effort); + } + if (null !== $assigneeId) { + $assignee = $this->userRepository->find($assigneeId); + if (null === $assignee) { + throw new \InvalidArgumentException(\sprintf('User with ID %d not found.', $assigneeId)); + } + $task->setAssignee($assignee); + } + if (null !== $groupId) { + $group = $this->taskGroupRepository->find($groupId); + if (null === $group) { + throw new \InvalidArgumentException(\sprintf('TaskGroup with ID %d not found.', $groupId)); + } + $task->setGroup($group); + } + if (null !== $tagIds) { + foreach ($tagIds as $tagId) { + $tag = $this->taskTagRepository->find($tagId); + if (null === $tag) { + throw new \InvalidArgumentException(\sprintf('TaskTag with ID %d not found.', $tagId)); + } + $task->addTag($tag); + } + } + + $this->entityManager->persist($task); + $this->entityManager->flush(); + + return json_encode([ + 'id' => $task->getId(), + 'number' => $task->getNumber(), + 'title' => $task->getTitle(), + 'project' => [ + 'id' => $project->getId(), + 'code' => $project->getCode(), + 'name' => $project->getName(), + ], + 'status' => $task->getStatus() ? ['id' => $task->getStatus()->getId(), 'label' => $task->getStatus()->getLabel()] : null, + 'priority' => $task->getPriority() ? ['id' => $task->getPriority()->getId(), 'label' => $task->getPriority()->getLabel()] : null, + 'effort' => $task->getEffort() ? ['id' => $task->getEffort()->getId(), 'label' => $task->getEffort()->getLabel()] : null, + 'assignee' => $task->getAssignee() ? ['id' => $task->getAssignee()->getId(), 'username' => $task->getAssignee()->getUsername()] : null, + 'group' => $task->getGroup() ? ['id' => $task->getGroup()->getId(), 'title' => $task->getGroup()->getTitle()] : null, + ]); + } +} +``` + +- [ ] **Step 2: Create UpdateTaskTool** + +Create `src/Mcp/Tool/Task/UpdateTaskTool.php`: + +```php +taskRepository->find($id); + + if (null === $task) { + throw new \InvalidArgumentException(\sprintf('Task with ID %d not found.', $id)); + } + + if (null !== $title) { + $task->setTitle($title); + } + if (null !== $description) { + $task->setDescription($description); + } + if (null !== $statusId) { + $status = $this->taskStatusRepository->find($statusId); + if (null === $status) { + throw new \InvalidArgumentException(\sprintf('TaskStatus with ID %d not found.', $statusId)); + } + $task->setStatus($status); + } + if (null !== $priorityId) { + $priority = $this->taskPriorityRepository->find($priorityId); + if (null === $priority) { + throw new \InvalidArgumentException(\sprintf('TaskPriority with ID %d not found.', $priorityId)); + } + $task->setPriority($priority); + } + if (null !== $effortId) { + $effort = $this->taskEffortRepository->find($effortId); + if (null === $effort) { + throw new \InvalidArgumentException(\sprintf('TaskEffort with ID %d not found.', $effortId)); + } + $task->setEffort($effort); + } + if (null !== $assigneeId) { + $assignee = $this->userRepository->find($assigneeId); + if (null === $assignee) { + throw new \InvalidArgumentException(\sprintf('User with ID %d not found.', $assigneeId)); + } + $task->setAssignee($assignee); + } + if (null !== $groupId) { + $group = $this->taskGroupRepository->find($groupId); + if (null === $group) { + throw new \InvalidArgumentException(\sprintf('TaskGroup with ID %d not found.', $groupId)); + } + $task->setGroup($group); + } + if (null !== $tagIds) { + // Clear existing tags and set new ones + foreach ($task->getTags()->toArray() as $existingTag) { + $task->removeTag($existingTag); + } + foreach ($tagIds as $tagId) { + $tag = $this->taskTagRepository->find($tagId); + if (null === $tag) { + throw new \InvalidArgumentException(\sprintf('TaskTag with ID %d not found.', $tagId)); + } + $task->addTag($tag); + } + } + if (null !== $archived) { + $task->setArchived($archived); + } + + $this->entityManager->flush(); + + return json_encode([ + 'id' => $task->getId(), + 'number' => $task->getNumber(), + 'title' => $task->getTitle(), + 'project' => [ + 'id' => $task->getProject()->getId(), + 'code' => $task->getProject()->getCode(), + ], + 'status' => $task->getStatus() ? ['id' => $task->getStatus()->getId(), 'label' => $task->getStatus()->getLabel()] : null, + 'priority' => $task->getPriority() ? ['id' => $task->getPriority()->getId(), 'label' => $task->getPriority()->getLabel()] : null, + 'assignee' => $task->getAssignee() ? ['id' => $task->getAssignee()->getId(), 'username' => $task->getAssignee()->getUsername()] : null, + 'archived' => $task->isArchived(), + ]); + } +} +``` + +- [ ] **Step 3: Create DeleteTaskTool** + +Create `src/Mcp/Tool/Task/DeleteTaskTool.php`: + +```php +taskRepository->find($id); + + if (null === $task) { + throw new \InvalidArgumentException(\sprintf('Task with ID %d not found.', $id)); + } + + $taskCode = $task->getProject()->getCode() . '-' . $task->getNumber(); + $this->entityManager->remove($task); + $this->entityManager->flush(); + + return json_encode([ + 'success' => true, + 'message' => \sprintf('Task %s deleted.', $taskCode), + ]); + } +} +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/Mcp/Tool/Task/ +git commit -m "feat : add create-task, update-task, delete-task MCP tools" +``` + +--- + +## Chunk 4: TaskMeta & TimeEntry Tools + +### Task 10: TaskMeta tools (statuses, priorities, efforts, tags, groups) + +**Files:** +- Create: `src/Mcp/Tool/TaskMeta/ListStatusesTool.php` +- Create: `src/Mcp/Tool/TaskMeta/ListPrioritiesTool.php` +- Create: `src/Mcp/Tool/TaskMeta/ListEffortsTool.php` +- Create: `src/Mcp/Tool/TaskMeta/ListTagsTool.php` +- Create: `src/Mcp/Tool/TaskMeta/ListGroupsTool.php` +- Create: `src/Mcp/Tool/TaskMeta/CreateGroupTool.php` +- Create: `src/Mcp/Tool/TaskMeta/UpdateGroupTool.php` + +- [ ] **Step 1: Create ListStatusesTool** + +Create `src/Mcp/Tool/TaskMeta/ListStatusesTool.php`: + +```php +taskStatusRepository->findBy([], ['position' => 'ASC']); + + return json_encode(array_map(fn($s) => [ + 'id' => $s->getId(), + 'label' => $s->getLabel(), + 'color' => $s->getColor(), + 'position' => $s->getPosition(), + 'isFinal' => $s->isFinal(), + ], $statuses)); + } +} +``` + +- [ ] **Step 2: Create ListPrioritiesTool** + +Create `src/Mcp/Tool/TaskMeta/ListPrioritiesTool.php`: + +```php +taskPriorityRepository->findBy([], ['label' => 'ASC']); + + return json_encode(array_map(fn($p) => [ + 'id' => $p->getId(), + 'label' => $p->getLabel(), + 'color' => $p->getColor(), + ], $priorities)); + } +} +``` + +- [ ] **Step 3: Create ListEffortsTool** + +Create `src/Mcp/Tool/TaskMeta/ListEffortsTool.php`: + +```php +taskEffortRepository->findBy([], ['label' => 'ASC']); + + return json_encode(array_map(fn($e) => [ + 'id' => $e->getId(), + 'label' => $e->getLabel(), + ], $efforts)); + } +} +``` + +- [ ] **Step 4: Create ListTagsTool** + +Create `src/Mcp/Tool/TaskMeta/ListTagsTool.php`: + +```php +taskTagRepository->findBy([], ['label' => 'ASC']); + + return json_encode(array_map(fn($t) => [ + 'id' => $t->getId(), + 'label' => $t->getLabel(), + 'color' => $t->getColor(), + ], $tags)); + } +} +``` + +- [ ] **Step 5: Create ListGroupsTool** + +Create `src/Mcp/Tool/TaskMeta/ListGroupsTool.php`: + +```php + $archived]; + if (null !== $projectId) { + $criteria['project'] = $projectId; + } + + $groups = $this->taskGroupRepository->findBy($criteria, ['title' => 'ASC']); + + return json_encode(array_map(fn($g) => [ + 'id' => $g->getId(), + 'title' => $g->getTitle(), + 'description' => $g->getDescription(), + 'color' => $g->getColor(), + 'project' => [ + 'id' => $g->getProject()->getId(), + 'code' => $g->getProject()->getCode(), + 'name' => $g->getProject()->getName(), + ], + 'archived' => $g->isArchived(), + ], $groups)); + } +} +``` + +- [ ] **Step 6: Create CreateGroupTool** + +Create `src/Mcp/Tool/TaskMeta/CreateGroupTool.php`: + +```php +projectRepository->find($projectId); + if (null === $project) { + throw new \InvalidArgumentException(\sprintf('Project with ID %d not found.', $projectId)); + } + + $group = new TaskGroup(); + $group->setProject($project); + $group->setTitle($title); + + if (null !== $description) { + $group->setDescription($description); + } + if (null !== $color) { + $group->setColor($color); + } + + $this->entityManager->persist($group); + $this->entityManager->flush(); + + return json_encode([ + 'id' => $group->getId(), + 'title' => $group->getTitle(), + 'description' => $group->getDescription(), + 'color' => $group->getColor(), + 'project' => [ + 'id' => $project->getId(), + 'code' => $project->getCode(), + 'name' => $project->getName(), + ], + 'archived' => $group->isArchived(), + ]); + } +} +``` + +- [ ] **Step 7: Create UpdateGroupTool** + +Create `src/Mcp/Tool/TaskMeta/UpdateGroupTool.php`: + +```php +taskGroupRepository->find($id); + + if (null === $group) { + throw new \InvalidArgumentException(\sprintf('TaskGroup with ID %d not found.', $id)); + } + + if (null !== $title) { + $group->setTitle($title); + } + if (null !== $description) { + $group->setDescription($description); + } + if (null !== $color) { + $group->setColor($color); + } + if (null !== $archived) { + $group->setArchived($archived); + } + + $this->entityManager->flush(); + + return json_encode([ + 'id' => $group->getId(), + 'title' => $group->getTitle(), + 'description' => $group->getDescription(), + 'color' => $group->getColor(), + 'project' => [ + 'id' => $group->getProject()->getId(), + 'code' => $group->getProject()->getCode(), + 'name' => $group->getProject()->getName(), + ], + 'archived' => $group->isArchived(), + ]); + } +} +``` + +- [ ] **Step 8: Commit** + +```bash +git add src/Mcp/Tool/TaskMeta/ +git commit -m "feat : add task metadata MCP tools (statuses, priorities, efforts, tags, groups CRUD)" +``` + +--- + +### Task 11: TimeEntry tools + +**Files:** +- Create: `src/Mcp/Tool/TimeEntry/ListTimeEntriesTool.php` +- Create: `src/Mcp/Tool/TimeEntry/CreateTimeEntryTool.php` +- Create: `src/Mcp/Tool/TimeEntry/UpdateTimeEntryTool.php` +- Create: `src/Mcp/Tool/TimeEntry/DeleteTimeEntryTool.php` + +- [ ] **Step 1: Create ListTimeEntriesTool** + +Create `src/Mcp/Tool/TimeEntry/ListTimeEntriesTool.php`: + +```php +timeEntryRepository->createQueryBuilder('te') + ->leftJoin('te.user', 'u')->addSelect('u') + ->leftJoin('te.project', 'p')->addSelect('p') + ->leftJoin('te.task', 't')->addSelect('t') + ->leftJoin('te.tags', 'tg')->addSelect('tg') + ->orderBy('te.startedAt', 'DESC') + ->setMaxResults($limit); + + if (null !== $userId) { + $qb->andWhere('u.id = :userId')->setParameter('userId', $userId); + } + if (null !== $projectId) { + $qb->andWhere('p.id = :projectId')->setParameter('projectId', $projectId); + } + if (null !== $taskId) { + $qb->andWhere('t.id = :taskId')->setParameter('taskId', $taskId); + } + if (null !== $startDate) { + $qb->andWhere('te.startedAt >= :startDate') + ->setParameter('startDate', new \DateTimeImmutable($startDate . ' 00:00:00')); + } + if (null !== $endDate) { + $qb->andWhere('te.startedAt <= :endDate') + ->setParameter('endDate', new \DateTimeImmutable($endDate . ' 23:59:59')); + } + + $entries = $qb->getQuery()->getResult(); + + return json_encode(array_map(fn($entry) => [ + 'id' => $entry->getId(), + 'title' => $entry->getTitle(), + 'description' => $entry->getDescription(), + 'startedAt' => $entry->getStartedAt()?->format('c'), + 'stoppedAt' => $entry->getStoppedAt()?->format('c'), + 'duration' => $entry->getStoppedAt() && $entry->getStartedAt() + ? (int) round(($entry->getStoppedAt()->getTimestamp() - $entry->getStartedAt()->getTimestamp()) / 60) + : null, + 'user' => [ + 'id' => $entry->getUser()->getId(), + 'username' => $entry->getUser()->getUsername(), + ], + 'project' => $entry->getProject() ? [ + 'id' => $entry->getProject()->getId(), + 'code' => $entry->getProject()->getCode(), + 'name' => $entry->getProject()->getName(), + ] : null, + 'task' => $entry->getTask() ? [ + 'id' => $entry->getTask()->getId(), + 'number' => $entry->getTask()->getNumber(), + 'title' => $entry->getTask()->getTitle(), + ] : null, + 'tags' => $entry->getTags()->map(fn($t) => [ + 'id' => $t->getId(), + 'label' => $t->getLabel(), + ])->toArray(), + ], $entries)); + } +} +``` + +- [ ] **Step 2: Create CreateTimeEntryTool** + +Create `src/Mcp/Tool/TimeEntry/CreateTimeEntryTool.php`: + +```php +userRepository->find($userId); + if (null === $user) { + throw new \InvalidArgumentException(\sprintf('User with ID %d not found.', $userId)); + } + + // Check for existing active timer if creating a new active one + if (null === $stoppedAt) { + $activeEntry = $this->timeEntryRepository->findActiveByUser($user); + if (null !== $activeEntry) { + throw new \InvalidArgumentException(\sprintf('User "%s" already has an active timer (ID %d). Stop it before starting a new one.', $user->getUsername(), $activeEntry->getId())); + } + } + + $entry = new TimeEntry(); + $entry->setUser($user); + $entry->setStartedAt(new \DateTimeImmutable($startedAt)); + + if (null !== $title) { + $entry->setTitle($title); + } + if (null !== $stoppedAt) { + $entry->setStoppedAt(new \DateTimeImmutable($stoppedAt)); + } + if (null !== $description) { + $entry->setDescription($description); + } + if (null !== $projectId) { + $project = $this->projectRepository->find($projectId); + if (null === $project) { + throw new \InvalidArgumentException(\sprintf('Project with ID %d not found.', $projectId)); + } + $entry->setProject($project); + } + if (null !== $taskId) { + $task = $this->taskRepository->find($taskId); + if (null === $task) { + throw new \InvalidArgumentException(\sprintf('Task with ID %d not found.', $taskId)); + } + $entry->setTask($task); + } + if (null !== $tagIds) { + foreach ($tagIds as $tagId) { + $tag = $this->taskTagRepository->find($tagId); + if (null === $tag) { + throw new \InvalidArgumentException(\sprintf('TaskTag with ID %d not found.', $tagId)); + } + $entry->addTag($tag); + } + } + + $this->entityManager->persist($entry); + $this->entityManager->flush(); + + return json_encode([ + 'id' => $entry->getId(), + 'title' => $entry->getTitle(), + 'startedAt' => $entry->getStartedAt()?->format('c'), + 'stoppedAt' => $entry->getStoppedAt()?->format('c'), + 'user' => ['id' => $user->getId(), 'username' => $user->getUsername()], + 'project' => $entry->getProject() ? ['id' => $entry->getProject()->getId(), 'code' => $entry->getProject()->getCode()] : null, + 'task' => $entry->getTask() ? ['id' => $entry->getTask()->getId(), 'number' => $entry->getTask()->getNumber()] : null, + ]); + } +} +``` + +- [ ] **Step 3: Create UpdateTimeEntryTool** + +Create `src/Mcp/Tool/TimeEntry/UpdateTimeEntryTool.php`: + +```php +timeEntryRepository->find($id); + + if (null === $entry) { + throw new \InvalidArgumentException(\sprintf('TimeEntry with ID %d not found.', $id)); + } + + if (null !== $title) { + $entry->setTitle($title); + } + if (null !== $startedAt) { + $entry->setStartedAt(new \DateTimeImmutable($startedAt)); + } + if (null !== $stoppedAt) { + $entry->setStoppedAt(new \DateTimeImmutable($stoppedAt)); + } + if (null !== $description) { + $entry->setDescription($description); + } + if (null !== $projectId) { + $project = $this->projectRepository->find($projectId); + if (null === $project) { + throw new \InvalidArgumentException(\sprintf('Project with ID %d not found.', $projectId)); + } + $entry->setProject($project); + } + if (null !== $taskId) { + $task = $this->taskRepository->find($taskId); + if (null === $task) { + throw new \InvalidArgumentException(\sprintf('Task with ID %d not found.', $taskId)); + } + $entry->setTask($task); + } + if (null !== $tagIds) { + foreach ($entry->getTags()->toArray() as $existingTag) { + $entry->removeTag($existingTag); + } + foreach ($tagIds as $tagId) { + $tag = $this->taskTagRepository->find($tagId); + if (null === $tag) { + throw new \InvalidArgumentException(\sprintf('TaskTag with ID %d not found.', $tagId)); + } + $entry->addTag($tag); + } + } + + $this->entityManager->flush(); + + return json_encode([ + 'id' => $entry->getId(), + 'title' => $entry->getTitle(), + 'startedAt' => $entry->getStartedAt()?->format('c'), + 'stoppedAt' => $entry->getStoppedAt()?->format('c'), + 'duration' => $entry->getStoppedAt() && $entry->getStartedAt() + ? (int) round(($entry->getStoppedAt()->getTimestamp() - $entry->getStartedAt()->getTimestamp()) / 60) + : null, + 'user' => ['id' => $entry->getUser()->getId(), 'username' => $entry->getUser()->getUsername()], + 'project' => $entry->getProject() ? ['id' => $entry->getProject()->getId(), 'code' => $entry->getProject()->getCode()] : null, + 'task' => $entry->getTask() ? ['id' => $entry->getTask()->getId(), 'number' => $entry->getTask()->getNumber()] : null, + ]); + } +} +``` + +- [ ] **Step 4: Create DeleteTimeEntryTool** + +Create `src/Mcp/Tool/TimeEntry/DeleteTimeEntryTool.php`: + +```php +timeEntryRepository->find($id); + + if (null === $entry) { + throw new \InvalidArgumentException(\sprintf('TimeEntry with ID %d not found.', $id)); + } + + $this->entityManager->remove($entry); + $this->entityManager->flush(); + + return json_encode([ + 'success' => true, + 'message' => 'Time entry deleted.', + ]); + } +} +``` + +- [ ] **Step 5: Commit** + +```bash +git add src/Mcp/Tool/TimeEntry/ +git commit -m "feat : add time entry MCP tools (list, create, update, delete)" +``` + +--- + +## Chunk 5: Integration Testing & Claude Code Setup + +### Task 12: End-to-end verification + +- [ ] **Step 1: Clear cache and verify all tools are registered** + +```bash +docker exec -u www-data php-lesstime-fpm php bin/console cache:clear +``` + +Then list all tools via STDIO: +```bash +echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | docker exec -i php-lesstime-fpm php bin/console mcp:server +``` + +Expected: JSON response listing all 21 tools: `list-users`, `list-clients`, `list-projects`, `get-project`, `create-project`, `update-project`, `list-tasks`, `get-task`, `create-task`, `update-task`, `delete-task`, `list-statuses`, `list-priorities`, `list-efforts`, `list-tags`, `list-groups`, `create-group`, `update-group`, `list-time-entries`, `create-time-entry`, `update-time-entry`, `delete-time-entry` (note: delete-time-entry = 22nd tool, but spec counts 21 — recheck). + +- [ ] **Step 2: Test a tool call via STDIO** + +```bash +echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list-projects","arguments":{}}}' | docker exec -i php-lesstime-fpm php bin/console mcp:server +``` + +Expected: JSON response with the list of fixture projects (SIRH, CRM, ERP, Site vitrine). + +- [ ] **Step 3: Test HTTP transport with auth** + +```bash +docker restart nginx-lesstime +``` + +Initialize MCP session: +```bash +curl -s -X POST http://localhost:8082/_mcp \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer dev-mcp-token-for-testing-only-do-not-use-in-production" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl-test","version":"1.0"}}}' +``` + +Expected: JSON-RPC response with server info and capabilities. + +- [ ] **Step 4: Test HTTP auth rejection** + +```bash +curl -s -X POST http://localhost:8082/_mcp \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer wrong-token" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl-test","version":"1.0"}}}' +``` + +Expected: 401 Unauthorized response. + +- [ ] **Step 5: Configure Claude Code (STDIO)** + +Add to `.claude/settings.json` (or project-level `.claude/settings.local.json`): + +```json +{ + "mcpServers": { + "lesstime": { + "command": "docker", + "args": ["exec", "-i", "php-lesstime-fpm", "php", "bin/console", "mcp:server"], + "cwd": "/home/r-dev/Lesstime" + } + } +} +``` + +- [ ] **Step 6: Run PHP CS Fixer on all new files** + +```bash +docker exec -u www-data php-lesstime-fpm vendor/bin/php-cs-fixer fix src/Mcp/ --rules=@Symfony,@PSR12 --allow-risky=yes +docker exec -u www-data php-lesstime-fpm vendor/bin/php-cs-fixer fix src/Security/ApiTokenAuthenticator.php --rules=@Symfony,@PSR12 --allow-risky=yes +docker exec -u www-data php-lesstime-fpm vendor/bin/php-cs-fixer fix src/Command/GenerateApiTokenCommand.php --rules=@Symfony,@PSR12 --allow-risky=yes +``` + +- [ ] **Step 7: Final commit if CS Fixer changed anything** + +```bash +git add -A src/Mcp/ src/Security/ src/Command/ +git diff --cached --quiet || git commit -m "style : apply PHP CS Fixer to MCP server code" +```