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>
68 KiB
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:
docker exec -u www-data php-lesstime-fpm composer require symfony/mcp-bundle
- Step 2: Create MCP config
Create config/packages/mcp.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:
mcp:
resource: .
type: mcp
- Step 4: Verify the bundle is loaded
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
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:
#[ORM\Column(length: 64, unique: true, nullable: true)]
private ?string $apiToken = null;
Add getter and setter after the eraseCredentials method:
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
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
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
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:
mcp:
pattern: ^/_mcp
stateless: true
provider: app_user_provider
custom_authenticators:
- App\Security\ApiTokenAuthenticator
Add access control rule before the existing /api rules:
- { path: ^/_mcp, roles: IS_AUTHENTICATED_FULLY }
- Step 3: Verify the firewall is registered
docker exec -u www-data php-lesstime-fpm php bin/console debug:firewall
Expected: mcp firewall appears in the list.
- Step 4: Commit
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
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
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
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:
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(...):
->setApiToken('dev-mcp-token-for-testing-only-do-not-use-in-production')
- Step 3: Restart Nginx and reload fixtures
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
docker exec -u www-data php-lesstime-fpm php bin/console cache:clear
Then list all tools via STDIO:
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
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
docker restart nginx-lesstime
Initialize MCP session:
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
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):
{
"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
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
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"