Files
Lesstime/docs/superpowers/plans/2026-03-15-mcp-server.md
matthieu 9a9416d6c8 fix : apply review fixes to MCP plan and spec
Fix getIsFinal() method name, enrich create/update tool return formats
to match get/list consistency, fix duplicate Reference section in spec,
correct tool count to 22.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:27:06 +01:00

2177 lines
68 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 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
<?php
declare(strict_types=1);
namespace App\Security;
use App\Repository\UserRepository;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
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;
class ApiTokenAuthenticator extends AbstractAuthenticator
{
public function __construct(
private readonly UserRepository $userRepository,
) {
}
public function supports(Request $request): ?bool
{
return $request->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
<?php
declare(strict_types=1);
namespace App\Command;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:generate-api-token',
description: 'Generate or regenerate an API token for a user (used for MCP HTTP authentication)',
)]
class GenerateApiTokenCommand extends Command
{
public function __construct(
private readonly UserRepository $userRepository,
private readonly EntityManagerInterface $entityManager,
) {
parent::__construct();
}
protected function configure(): void
{
$this->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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Reference;
use App\Repository\UserRepository;
use Mcp\Capability\Attribute\McpTool;
class ListUsersTool
{
public function __construct(
private readonly UserRepository $userRepository,
) {
}
#[McpTool(name: 'list-users', description: 'List all users with their IDs and usernames. Use this to discover valid user IDs for assignee or time entry parameters.')]
public function __invoke(): string
{
$users = $this->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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Reference;
use App\Repository\ClientRepository;
use Mcp\Capability\Attribute\McpTool;
class ListClientsTool
{
public function __construct(
private readonly ClientRepository $clientRepository,
) {
}
#[McpTool(name: 'list-clients', description: 'List all clients with their IDs, names, and emails. Use this to discover valid client IDs for project parameters.')]
public function __invoke(): string
{
$clients = $this->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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Project;
use App\Repository\ProjectRepository;
use Mcp\Capability\Attribute\McpTool;
class ListProjectsTool
{
public function __construct(
private readonly ProjectRepository $projectRepository,
) {
}
#[McpTool(name: 'list-projects', description: 'List all projects with optional archive filter')]
public function __invoke(bool $archived = false): string
{
$projects = $this->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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Project;
use App\Repository\ProjectRepository;
use App\Repository\TaskRepository;
use Mcp\Capability\Attribute\McpTool;
class GetProjectTool
{
public function __construct(
private readonly ProjectRepository $projectRepository,
private readonly TaskRepository $taskRepository,
) {
}
#[McpTool(name: 'get-project', description: 'Get project details with task count summary per status')]
public function __invoke(int $id): string
{
$project = $this->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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Project;
use App\Entity\Project;
use App\Repository\ClientRepository;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
class CreateProjectTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly ClientRepository $clientRepository,
) {
}
#[McpTool(name: 'create-project', description: 'Create a new project. Code must be 2-10 uppercase letters.')]
public function __invoke(
string $name,
string $code,
?string $description = null,
?string $color = null,
?int $clientId = null,
): string {
$project = new Project();
$project->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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Project;
use App\Repository\ClientRepository;
use App\Repository\ProjectRepository;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
class UpdateProjectTool
{
public function __construct(
private readonly ProjectRepository $projectRepository,
private readonly ClientRepository $clientRepository,
private readonly EntityManagerInterface $entityManager,
) {
}
#[McpTool(name: 'update-project', description: 'Update an existing project. Only provided fields are changed.')]
public function __invoke(
int $id,
?string $name = null,
?string $code = null,
?string $description = null,
?string $color = null,
?int $clientId = null,
?bool $archived = null,
): string {
$project = $this->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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Task;
use App\Repository\TaskRepository;
use Mcp\Capability\Attribute\McpTool;
class ListTasksTool
{
public function __construct(
private readonly TaskRepository $taskRepository,
) {
}
#[McpTool(name: 'list-tasks', description: 'List tasks with optional filters by project, status, assignee, priority, group, tags, and archive state. Returns max 100 results by default, use filters to narrow down.')]
public function __invoke(
?int $projectId = null,
?int $statusId = null,
?int $assigneeId = null,
?int $priorityId = null,
?int $groupId = null,
?array $tagIds = null,
bool $archived = false,
int $limit = 100,
): string {
$limit = min($limit, 200);
$qb = $this->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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Task;
use App\Repository\TaskRepository;
use Mcp\Capability\Attribute\McpTool;
class GetTaskTool
{
public function __construct(
private readonly TaskRepository $taskRepository,
) {
}
#[McpTool(name: 'get-task', description: 'Get full task details including description, all relations, and documents')]
public function __invoke(int $id): string
{
$task = $this->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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Task;
use App\Entity\Task;
use App\Repository\ProjectRepository;
use App\Repository\TaskEffortRepository;
use App\Repository\TaskGroupRepository;
use App\Repository\TaskPriorityRepository;
use App\Repository\TaskRepository;
use App\Repository\TaskStatusRepository;
use App\Repository\TaskTagRepository;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
class CreateTaskTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly ProjectRepository $projectRepository,
private readonly TaskRepository $taskRepository,
private readonly TaskStatusRepository $taskStatusRepository,
private readonly TaskPriorityRepository $taskPriorityRepository,
private readonly TaskEffortRepository $taskEffortRepository,
private readonly TaskGroupRepository $taskGroupRepository,
private readonly TaskTagRepository $taskTagRepository,
private readonly UserRepository $userRepository,
) {
}
#[McpTool(name: 'create-task', description: 'Create a new task in a project. The task number is auto-generated. Use list-statuses, list-priorities, list-efforts, list-tags, list-groups to discover valid IDs.')]
public function __invoke(
int $projectId,
string $title,
?string $description = null,
?int $statusId = null,
?int $priorityId = null,
?int $effortId = null,
?int $assigneeId = null,
?int $groupId = null,
?array $tagIds = null,
): string {
$project = $this->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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Task;
use App\Repository\TaskEffortRepository;
use App\Repository\TaskGroupRepository;
use App\Repository\TaskPriorityRepository;
use App\Repository\TaskRepository;
use App\Repository\TaskStatusRepository;
use App\Repository\TaskTagRepository;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
class UpdateTaskTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly TaskRepository $taskRepository,
private readonly TaskStatusRepository $taskStatusRepository,
private readonly TaskPriorityRepository $taskPriorityRepository,
private readonly TaskEffortRepository $taskEffortRepository,
private readonly TaskGroupRepository $taskGroupRepository,
private readonly TaskTagRepository $taskTagRepository,
private readonly UserRepository $userRepository,
) {
}
#[McpTool(name: 'update-task', description: 'Update an existing task. Only provided fields are changed. Use list-statuses, list-priorities, etc. to discover valid IDs.')]
public function __invoke(
int $id,
?string $title = null,
?string $description = null,
?int $statusId = null,
?int $priorityId = null,
?int $effortId = null,
?int $assigneeId = null,
?int $groupId = null,
?array $tagIds = null,
?bool $archived = null,
): string {
$task = $this->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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Task;
use App\Repository\TaskRepository;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
class DeleteTaskTool
{
public function __construct(
private readonly TaskRepository $taskRepository,
private readonly EntityManagerInterface $entityManager,
) {
}
#[McpTool(name: 'delete-task', description: 'Delete a task permanently. This also deletes all associated documents.')]
public function __invoke(int $id): string
{
$task = $this->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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Repository\TaskStatusRepository;
use Mcp\Capability\Attribute\McpTool;
class ListStatusesTool
{
public function __construct(
private readonly TaskStatusRepository $taskStatusRepository,
) {
}
#[McpTool(name: 'list-statuses', description: 'List all task statuses ordered by position. Statuses are global (shared across all projects). Use the returned IDs when creating or updating tasks.')]
public function __invoke(): string
{
$statuses = $this->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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Repository\TaskPriorityRepository;
use Mcp\Capability\Attribute\McpTool;
class ListPrioritiesTool
{
public function __construct(
private readonly TaskPriorityRepository $taskPriorityRepository,
) {
}
#[McpTool(name: 'list-priorities', description: 'List all task priorities. Priorities are global (shared across all projects).')]
public function __invoke(): string
{
$priorities = $this->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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Repository\TaskEffortRepository;
use Mcp\Capability\Attribute\McpTool;
class ListEffortsTool
{
public function __construct(
private readonly TaskEffortRepository $taskEffortRepository,
) {
}
#[McpTool(name: 'list-efforts', description: 'List all task effort levels. Efforts are global (shared across all projects).')]
public function __invoke(): string
{
$efforts = $this->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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Repository\TaskTagRepository;
use Mcp\Capability\Attribute\McpTool;
class ListTagsTool
{
public function __construct(
private readonly TaskTagRepository $taskTagRepository,
) {
}
#[McpTool(name: 'list-tags', description: 'List all task tags. Tags are global (shared across all projects).')]
public function __invoke(): string
{
$tags = $this->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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Repository\TaskGroupRepository;
use Mcp\Capability\Attribute\McpTool;
class ListGroupsTool
{
public function __construct(
private readonly TaskGroupRepository $taskGroupRepository,
) {
}
#[McpTool(name: 'list-groups', description: 'List task groups, optionally filtered by project. Groups are per-project (each group belongs to one project).')]
public function __invoke(?int $projectId = null, bool $archived = false): string
{
$criteria = ['archived' => $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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Entity\TaskGroup;
use App\Repository\ProjectRepository;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
class CreateGroupTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly ProjectRepository $projectRepository,
) {
}
#[McpTool(name: 'create-group', description: 'Create a new task group for a project')]
public function __invoke(
int $projectId,
string $title,
?string $description = null,
?string $color = null,
): string {
$project = $this->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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Repository\TaskGroupRepository;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
class UpdateGroupTool
{
public function __construct(
private readonly TaskGroupRepository $taskGroupRepository,
private readonly EntityManagerInterface $entityManager,
) {
}
#[McpTool(name: 'update-group', description: 'Update an existing task group. Only provided fields are changed.')]
public function __invoke(
int $id,
?string $title = null,
?string $description = null,
?string $color = null,
?bool $archived = null,
): string {
$group = $this->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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TimeEntry;
use App\Repository\TimeEntryRepository;
use Mcp\Capability\Attribute\McpTool;
class ListTimeEntriesTool
{
public function __construct(
private readonly TimeEntryRepository $timeEntryRepository,
) {
}
#[McpTool(name: 'list-time-entries', description: 'List time entries with optional filters. Duration is computed in minutes and null for active timers.')]
public function __invoke(
?int $userId = null,
?int $projectId = null,
?int $taskId = null,
?string $startDate = null,
?string $endDate = null,
int $limit = 100,
): string {
$limit = min($limit, 200);
$qb = $this->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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TimeEntry;
use App\Entity\TimeEntry;
use App\Repository\ProjectRepository;
use App\Repository\TaskRepository;
use App\Repository\TaskTagRepository;
use App\Repository\TimeEntryRepository;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
class CreateTimeEntryTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly UserRepository $userRepository,
private readonly ProjectRepository $projectRepository,
private readonly TaskRepository $taskRepository,
private readonly TaskTagRepository $taskTagRepository,
private readonly TimeEntryRepository $timeEntryRepository,
) {
}
#[McpTool(name: 'create-time-entry', description: 'Create a time entry. If stoppedAt is null, creates an active timer. Only one active timer per user is allowed.')]
public function __invoke(
int $userId,
string $startedAt,
?string $title = null,
?string $stoppedAt = null,
?int $projectId = null,
?int $taskId = null,
?array $tagIds = null,
?string $description = null,
): string {
$user = $this->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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TimeEntry;
use App\Repository\ProjectRepository;
use App\Repository\TaskRepository;
use App\Repository\TaskTagRepository;
use App\Repository\TimeEntryRepository;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
class UpdateTimeEntryTool
{
public function __construct(
private readonly TimeEntryRepository $timeEntryRepository,
private readonly ProjectRepository $projectRepository,
private readonly TaskRepository $taskRepository,
private readonly TaskTagRepository $taskTagRepository,
private readonly EntityManagerInterface $entityManager,
) {
}
#[McpTool(name: 'update-time-entry', description: 'Update a time entry. Use to stop an active timer by providing stoppedAt, or to correct start time. userId is not updatable.')]
public function __invoke(
int $id,
?string $title = null,
?string $startedAt = null,
?string $stoppedAt = null,
?int $projectId = null,
?int $taskId = null,
?array $tagIds = null,
?string $description = null,
): string {
$entry = $this->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
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TimeEntry;
use App\Repository\TimeEntryRepository;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
class DeleteTimeEntryTool
{
public function __construct(
private readonly TimeEntryRepository $timeEntryRepository,
private readonly EntityManagerInterface $entityManager,
) {
}
#[McpTool(name: 'delete-time-entry', description: 'Delete a time entry permanently')]
public function __invoke(int $id): string
{
$entry = $this->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"
```