feat : update MCP tools with calendar fields and add recurrence tools

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-03-19 10:23:25 +01:00
parent b3d317284e
commit cb768e0ce1
6 changed files with 330 additions and 24 deletions

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Task;
use App\Entity\TaskRecurrence;
use App\Enum\RecurrenceType;
use App\Repository\TaskRepository;
use App\Service\CalDavService;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'create-task-recurrence', description: 'Create a recurrence pattern for a task. Type: daily, weekly, monthly, yearly. For weekly, provide daysOfWeek array (e.g. ["monday","wednesday"]). For monthly, provide dayOfMonth OR weekOfMonth.')]
class CreateTaskRecurrenceTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly TaskRepository $taskRepository,
private readonly Security $security,
private readonly CalDavService $calDavService,
) {}
public function __invoke(
int $taskId,
string $type,
int $interval = 1,
?array $daysOfWeek = null,
?int $dayOfMonth = null,
?int $weekOfMonth = null,
?string $endDate = null,
?int $maxOccurrences = null,
): string {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$task = $this->taskRepository->find($taskId);
if (null === $task) {
throw new InvalidArgumentException(sprintf('Task with ID %d not found.', $taskId));
}
$recurrenceType = RecurrenceType::from($type);
$recurrence = new TaskRecurrence();
$recurrence->setType($recurrenceType);
$recurrence->setInterval($interval);
if (null !== $daysOfWeek) {
$recurrence->setDaysOfWeek($daysOfWeek);
}
if (null !== $dayOfMonth) {
$recurrence->setDayOfMonth($dayOfMonth);
}
if (null !== $weekOfMonth) {
$recurrence->setWeekOfMonth($weekOfMonth);
}
if (null !== $endDate) {
$recurrence->setEndDate(new DateTimeImmutable($endDate));
}
if (null !== $maxOccurrences) {
$recurrence->setMaxOccurrences($maxOccurrences);
}
$task->setRecurrence($recurrence);
$this->entityManager->persist($recurrence);
$this->entityManager->flush();
$this->calDavService->syncTask($task);
$this->entityManager->flush();
return json_encode([
'id' => $recurrence->getId(),
'type' => $recurrence->getType()?->value,
'interval' => $recurrence->getInterval(),
'daysOfWeek' => $recurrence->getDaysOfWeek(),
'dayOfMonth' => $recurrence->getDayOfMonth(),
'weekOfMonth' => $recurrence->getWeekOfMonth(),
'endDate' => $recurrence->getEndDate()?->format('Y-m-d'),
'maxOccurrences' => $recurrence->getMaxOccurrences(),
'taskId' => $task->getId(),
]);
}
}

View File

@@ -14,6 +14,8 @@ use App\Repository\TaskRepository;
use App\Repository\TaskStatusRepository; use App\Repository\TaskStatusRepository;
use App\Repository\TaskTagRepository; use App\Repository\TaskTagRepository;
use App\Repository\UserRepository; use App\Repository\UserRepository;
use App\Service\CalDavService;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException; use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool; use Mcp\Capability\Attribute\McpTool;
@@ -36,6 +38,7 @@ class CreateTaskTool
private readonly TaskTagRepository $taskTagRepository, private readonly TaskTagRepository $taskTagRepository,
private readonly UserRepository $userRepository, private readonly UserRepository $userRepository,
private readonly Security $security, private readonly Security $security,
private readonly CalDavService $calDavService,
) {} ) {}
public function __invoke( public function __invoke(
@@ -48,6 +51,10 @@ class CreateTaskTool
?int $assigneeId = null, ?int $assigneeId = null,
?int $groupId = null, ?int $groupId = null,
?array $tagIds = null, ?array $tagIds = null,
?string $scheduledStart = null,
?string $scheduledEnd = null,
?string $deadline = null,
?bool $syncToCalendar = null,
): string { ): string {
if (!$this->security->isGranted('ROLE_USER')) { if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.'); throw new AccessDeniedException('Access denied: ROLE_USER required.');
@@ -109,6 +116,18 @@ class CreateTaskTool
$task->addTag($tag); $task->addTag($tag);
} }
} }
if (null !== $scheduledStart) {
$task->setScheduledStart(new DateTimeImmutable($scheduledStart));
}
if (null !== $scheduledEnd) {
$task->setScheduledEnd(new DateTimeImmutable($scheduledEnd));
}
if (null !== $deadline) {
$task->setDeadline(new DateTimeImmutable($deadline));
}
if (null !== $syncToCalendar) {
$task->setSyncToCalendar($syncToCalendar);
}
$this->entityManager->wrapInTransaction(function () use ($task, $project): void { $this->entityManager->wrapInTransaction(function () use ($task, $project): void {
$task->setNumber($this->taskRepository->findMaxNumberByProjectForUpdate($project) + 1); $task->setNumber($this->taskRepository->findMaxNumberByProjectForUpdate($project) + 1);
@@ -116,19 +135,26 @@ class CreateTaskTool
$this->entityManager->flush(); $this->entityManager->flush();
}); });
$this->calDavService->syncTask($task);
$this->entityManager->flush();
return json_encode([ return json_encode([
'id' => $task->getId(), 'id' => $task->getId(),
'number' => $task->getNumber(), 'number' => $task->getNumber(),
'title' => $task->getTitle(), 'title' => $task->getTitle(),
'description' => $task->getDescription(), 'description' => $task->getDescription(),
'status' => Serializer::status($task->getStatus()), 'status' => Serializer::status($task->getStatus()),
'priority' => Serializer::priority($task->getPriority()), 'priority' => Serializer::priority($task->getPriority()),
'effort' => Serializer::effort($task->getEffort()), 'effort' => Serializer::effort($task->getEffort()),
'assignee' => Serializer::user($task->getAssignee()), 'assignee' => Serializer::user($task->getAssignee()),
'group' => Serializer::groupRef($task->getGroup()), 'group' => Serializer::groupRef($task->getGroup()),
'project' => Serializer::projectRef($project), 'project' => Serializer::projectRef($project),
'tags' => Serializer::tags($task->getTags()), 'tags' => Serializer::tags($task->getTags()),
'archived' => $task->isArchived(), 'archived' => $task->isArchived(),
'scheduledStart' => $task->getScheduledStart()?->format('c'),
'scheduledEnd' => $task->getScheduledEnd()?->format('c'),
'deadline' => $task->getDeadline()?->format('c'),
'syncToCalendar' => $task->isSyncToCalendar(),
]); ]);
} }
} }

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Task;
use App\Repository\TaskRecurrenceRepository;
use App\Service\CalDavService;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'delete-task-recurrence', description: 'Delete a task recurrence pattern. Nullifies the recurrence on the active task and removes the recurring calendar event.')]
class DeleteTaskRecurrenceTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly TaskRecurrenceRepository $taskRecurrenceRepository,
private readonly Security $security,
private readonly CalDavService $calDavService,
) {}
public function __invoke(int $recurrenceId): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$recurrence = $this->taskRecurrenceRepository->find($recurrenceId);
if (null === $recurrence) {
throw new InvalidArgumentException(sprintf('TaskRecurrence with ID %d not found.', $recurrenceId));
}
$tasks = $recurrence->getTasks()->toArray();
$eventUidToDelete = null;
foreach ($tasks as $task) {
if (null !== $task->getCalendarEventUid()) {
$eventUidToDelete = $task->getCalendarEventUid();
break;
}
}
foreach ($tasks as $task) {
$task->setRecurrence(null);
}
$this->entityManager->remove($recurrence);
$this->entityManager->flush();
if (null !== $eventUidToDelete) {
$this->calDavService->deleteEvent($eventUidToDelete);
}
return json_encode([
'success' => true,
'message' => sprintf('TaskRecurrence %d deleted.', $recurrenceId),
'tasksUpdated' => count($tasks),
]);
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Mcp\Tool\Task; namespace App\Mcp\Tool\Task;
use App\Repository\TaskRepository; use App\Repository\TaskRepository;
use App\Service\CalDavService;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException; use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool; use Mcp\Capability\Attribute\McpTool;
@@ -20,6 +21,7 @@ class DeleteTaskTool
private readonly TaskRepository $taskRepository, private readonly TaskRepository $taskRepository,
private readonly EntityManagerInterface $entityManager, private readonly EntityManagerInterface $entityManager,
private readonly Security $security, private readonly Security $security,
private readonly CalDavService $calDavService,
) {} ) {}
public function __invoke(int $id): string public function __invoke(int $id): string
@@ -35,9 +37,18 @@ class DeleteTaskTool
} }
$taskCode = $task->getProject()->getCode().'-'.$task->getNumber(); $taskCode = $task->getProject()->getCode().'-'.$task->getNumber();
$eventUid = $task->getCalendarEventUid();
$todoUid = $task->getCalendarTodoUid();
$this->entityManager->remove($task); $this->entityManager->remove($task);
$this->entityManager->flush(); $this->entityManager->flush();
if (null !== $eventUid) {
$this->calDavService->deleteEvent($eventUid);
}
if (null !== $todoUid) {
$this->calDavService->deleteTodo($todoUid);
}
return json_encode([ return json_encode([
'success' => true, 'success' => true,
'message' => sprintf('Task %s deleted.', $taskCode), 'message' => sprintf('Task %s deleted.', $taskCode),

View File

@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Task;
use App\Enum\RecurrenceType;
use App\Repository\TaskRecurrenceRepository;
use App\Service\CalDavService;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'update-task-recurrence', description: 'Update an existing task recurrence pattern.')]
class UpdateTaskRecurrenceTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly TaskRecurrenceRepository $taskRecurrenceRepository,
private readonly Security $security,
private readonly CalDavService $calDavService,
) {}
public function __invoke(
int $recurrenceId,
?string $type = null,
?int $interval = null,
?array $daysOfWeek = null,
?int $dayOfMonth = null,
?int $weekOfMonth = null,
?string $endDate = null,
?int $maxOccurrences = null,
): string {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$recurrence = $this->taskRecurrenceRepository->find($recurrenceId);
if (null === $recurrence) {
throw new InvalidArgumentException(sprintf('TaskRecurrence with ID %d not found.', $recurrenceId));
}
if (null !== $type) {
$recurrence->setType(RecurrenceType::from($type));
}
if (null !== $interval) {
$recurrence->setInterval($interval);
}
if (null !== $daysOfWeek) {
$recurrence->setDaysOfWeek($daysOfWeek);
}
if (null !== $dayOfMonth) {
$recurrence->setDayOfMonth($dayOfMonth);
}
if (null !== $weekOfMonth) {
$recurrence->setWeekOfMonth($weekOfMonth);
}
if (null !== $endDate) {
$recurrence->setEndDate(new DateTimeImmutable($endDate));
}
if (null !== $maxOccurrences) {
$recurrence->setMaxOccurrences($maxOccurrences);
}
$this->entityManager->flush();
foreach ($recurrence->getTasks() as $task) {
$this->calDavService->syncTask($task);
}
$this->entityManager->flush();
return json_encode([
'id' => $recurrence->getId(),
'type' => $recurrence->getType()?->value,
'interval' => $recurrence->getInterval(),
'daysOfWeek' => $recurrence->getDaysOfWeek(),
'dayOfMonth' => $recurrence->getDayOfMonth(),
'weekOfMonth' => $recurrence->getWeekOfMonth(),
'endDate' => $recurrence->getEndDate()?->format('Y-m-d'),
'maxOccurrences' => $recurrence->getMaxOccurrences(),
]);
}
}

View File

@@ -12,6 +12,8 @@ use App\Repository\TaskRepository;
use App\Repository\TaskStatusRepository; use App\Repository\TaskStatusRepository;
use App\Repository\TaskTagRepository; use App\Repository\TaskTagRepository;
use App\Repository\UserRepository; use App\Repository\UserRepository;
use App\Service\CalDavService;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException; use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool; use Mcp\Capability\Attribute\McpTool;
@@ -33,6 +35,7 @@ class UpdateTaskTool
private readonly TaskTagRepository $taskTagRepository, private readonly TaskTagRepository $taskTagRepository,
private readonly UserRepository $userRepository, private readonly UserRepository $userRepository,
private readonly Security $security, private readonly Security $security,
private readonly CalDavService $calDavService,
) {} ) {}
public function __invoke( public function __invoke(
@@ -46,6 +49,10 @@ class UpdateTaskTool
?int $groupId = null, ?int $groupId = null,
?array $tagIds = null, ?array $tagIds = null,
?bool $archived = null, ?bool $archived = null,
?string $scheduledStart = null,
?string $scheduledEnd = null,
?string $deadline = null,
?bool $syncToCalendar = null,
): string { ): string {
if (!$this->security->isGranted('ROLE_USER')) { if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.'); throw new AccessDeniedException('Access denied: ROLE_USER required.');
@@ -114,22 +121,40 @@ class UpdateTaskTool
if (null !== $archived) { if (null !== $archived) {
$task->setArchived($archived); $task->setArchived($archived);
} }
if (null !== $scheduledStart) {
$task->setScheduledStart(new DateTimeImmutable($scheduledStart));
}
if (null !== $scheduledEnd) {
$task->setScheduledEnd(new DateTimeImmutable($scheduledEnd));
}
if (null !== $deadline) {
$task->setDeadline(new DateTimeImmutable($deadline));
}
if (null !== $syncToCalendar) {
$task->setSyncToCalendar($syncToCalendar);
}
$this->entityManager->flush();
$this->calDavService->syncTask($task);
$this->entityManager->flush(); $this->entityManager->flush();
return json_encode([ return json_encode([
'id' => $task->getId(), 'id' => $task->getId(),
'number' => $task->getNumber(), 'number' => $task->getNumber(),
'title' => $task->getTitle(), 'title' => $task->getTitle(),
'description' => $task->getDescription(), 'description' => $task->getDescription(),
'status' => Serializer::status($task->getStatus()), 'status' => Serializer::status($task->getStatus()),
'priority' => Serializer::priority($task->getPriority()), 'priority' => Serializer::priority($task->getPriority()),
'effort' => Serializer::effort($task->getEffort()), 'effort' => Serializer::effort($task->getEffort()),
'assignee' => Serializer::user($task->getAssignee()), 'assignee' => Serializer::user($task->getAssignee()),
'group' => Serializer::groupRef($task->getGroup()), 'group' => Serializer::groupRef($task->getGroup()),
'project' => Serializer::projectRef($task->getProject()), 'project' => Serializer::projectRef($task->getProject()),
'tags' => Serializer::tags($task->getTags()), 'tags' => Serializer::tags($task->getTags()),
'archived' => $task->isArchived(), 'archived' => $task->isArchived(),
'scheduledStart' => $task->getScheduledStart()?->format('c'),
'scheduledEnd' => $task->getScheduledEnd()?->format('c'),
'deadline' => $task->getDeadline()?->format('c'),
'syncToCalendar' => $task->isSyncToCalendar(),
]); ]);
} }
} }