# 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 22 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()->getIsFinal(), ] : 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(), 'description' => $task->getDescription(), '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, '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, 'project' => [ 'id' => $project->getId(), 'code' => $project->getCode(), 'name' => $project->getName(), ], 'tags' => $task->getTags()->map(fn($t) => [ 'id' => $t->getId(), 'label' => $t->getLabel(), ])->toArray(), 'archived' => $task->isArchived(), ]); } } ``` - [ ] **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(), 'description' => $task->getDescription(), '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, '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, '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(), ]); } } ``` - [ ] **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->getIsFinal(), ], $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(), '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' => $user->getId(), 'username' => $user->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(), ]); } } ``` - [ ] **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(), '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(), ]); } } ``` - [ ] **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" ```