From 3d1a510d82c22ae77606d3e057e6b38503cf57b9 Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 19:45:39 +0100 Subject: [PATCH] feat : add 22 MCP tool classes for projects, tasks, and time tracking Tools: list-users, list-clients, list/get/create/update-project, list/get/create/update/delete-task, list-statuses/priorities/efforts/tags, list/create/update-group, list/create/update/delete-time-entry. Attribute moved to class level for SDK discovery compatibility. Install nyholm/psr7 for HTTP transport PSR-17 support. Co-Authored-By: Claude Opus 4.6 (1M context) --- composer.json | 1 + composer.lock | 80 +++++++++- src/Mcp/Tool/Project/CreateProjectTool.php | 64 ++++++++ src/Mcp/Tool/Project/GetProjectTool.php | 63 ++++++++ src/Mcp/Tool/Project/ListProjectsTool.php | 34 ++++ src/Mcp/Tool/Project/UpdateProjectTool.php | 77 +++++++++ src/Mcp/Tool/Reference/ListClientsTool.php | 27 ++++ src/Mcp/Tool/Reference/ListUsersTool.php | 26 +++ src/Mcp/Tool/Task/CreateTaskTool.php | 148 +++++++++++++++++ src/Mcp/Tool/Task/DeleteTaskTool.php | 39 +++++ src/Mcp/Tool/Task/GetTaskTool.php | 81 ++++++++++ src/Mcp/Tool/Task/ListTasksTool.php | 107 +++++++++++++ src/Mcp/Tool/Task/UpdateTaskTool.php | 151 ++++++++++++++++++ src/Mcp/Tool/TaskMeta/CreateGroupTool.php | 61 +++++++ src/Mcp/Tool/TaskMeta/ListEffortsTool.php | 26 +++ src/Mcp/Tool/TaskMeta/ListGroupsTool.php | 39 +++++ src/Mcp/Tool/TaskMeta/ListPrioritiesTool.php | 27 ++++ src/Mcp/Tool/TaskMeta/ListStatusesTool.php | 29 ++++ src/Mcp/Tool/TaskMeta/ListTagsTool.php | 27 ++++ src/Mcp/Tool/TaskMeta/UpdateGroupTool.php | 63 ++++++++ .../Tool/TimeEntry/CreateTimeEntryTool.php | 121 ++++++++++++++ .../Tool/TimeEntry/DeleteTimeEntryTool.php | 38 +++++ .../Tool/TimeEntry/ListTimeEntriesTool.php | 88 ++++++++++ .../Tool/TimeEntry/UpdateTimeEntryTool.php | 111 +++++++++++++ 24 files changed, 1527 insertions(+), 1 deletion(-) create mode 100644 src/Mcp/Tool/Project/CreateProjectTool.php create mode 100644 src/Mcp/Tool/Project/GetProjectTool.php create mode 100644 src/Mcp/Tool/Project/ListProjectsTool.php create mode 100644 src/Mcp/Tool/Project/UpdateProjectTool.php create mode 100644 src/Mcp/Tool/Reference/ListClientsTool.php create mode 100644 src/Mcp/Tool/Reference/ListUsersTool.php create mode 100644 src/Mcp/Tool/Task/CreateTaskTool.php create mode 100644 src/Mcp/Tool/Task/DeleteTaskTool.php create mode 100644 src/Mcp/Tool/Task/GetTaskTool.php create mode 100644 src/Mcp/Tool/Task/ListTasksTool.php create mode 100644 src/Mcp/Tool/Task/UpdateTaskTool.php create mode 100644 src/Mcp/Tool/TaskMeta/CreateGroupTool.php create mode 100644 src/Mcp/Tool/TaskMeta/ListEffortsTool.php create mode 100644 src/Mcp/Tool/TaskMeta/ListGroupsTool.php create mode 100644 src/Mcp/Tool/TaskMeta/ListPrioritiesTool.php create mode 100644 src/Mcp/Tool/TaskMeta/ListStatusesTool.php create mode 100644 src/Mcp/Tool/TaskMeta/ListTagsTool.php create mode 100644 src/Mcp/Tool/TaskMeta/UpdateGroupTool.php create mode 100644 src/Mcp/Tool/TimeEntry/CreateTimeEntryTool.php create mode 100644 src/Mcp/Tool/TimeEntry/DeleteTimeEntryTool.php create mode 100644 src/Mcp/Tool/TimeEntry/ListTimeEntriesTool.php create mode 100644 src/Mcp/Tool/TimeEntry/UpdateTimeEntryTool.php diff --git a/composer.json b/composer.json index 460ec59..c8a5a8a 100644 --- a/composer.json +++ b/composer.json @@ -14,6 +14,7 @@ "doctrine/orm": "^3.6", "lexik/jwt-authentication-bundle": "^3.2", "nelmio/cors-bundle": "^2.6", + "nyholm/psr7": "^1.8", "phpdocumentor/reflection-docblock": "^5.6|^6.0", "phpstan/phpdoc-parser": "^2.3", "symfony/asset": "8.0.*", diff --git a/composer.lock b/composer.lock index 92ed6dd..be8f461 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "75456bd21a6cc5bf33989f885a1ab515", + "content-hash": "75b9dbecf38167d0554dfd64a986a40e", "packages": [ { "name": "api-platform/doctrine-common", @@ -2690,6 +2690,84 @@ }, "time": "2026-01-12T15:59:08+00:00" }, + { + "name": "nyholm/psr7", + "version": "1.8.2", + "source": { + "type": "git", + "url": "https://github.com/Nyholm/psr7.git", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Nyholm/psr7/zipball/a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0" + }, + "provide": { + "php-http/message-factory-implementation": "1.0", + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "http-interop/http-factory-tests": "^0.9", + "php-http/message-factory": "^1.0", + "php-http/psr7-integration-tests": "^1.0", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.4", + "symfony/error-handler": "^4.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.8-dev" + } + }, + "autoload": { + "psr-4": { + "Nyholm\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + }, + { + "name": "Martijn van der Ven", + "email": "martijn@vanderven.se" + } + ], + "description": "A fast PHP7 implementation of PSR-7", + "homepage": "https://tnyholm.se", + "keywords": [ + "psr-17", + "psr-7" + ], + "support": { + "issues": "https://github.com/Nyholm/psr7/issues", + "source": "https://github.com/Nyholm/psr7/tree/1.8.2" + }, + "funding": [ + { + "url": "https://github.com/Zegnat", + "type": "github" + }, + { + "url": "https://github.com/nyholm", + "type": "github" + } + ], + "time": "2024-09-09T07:06:30+00:00" + }, { "name": "opis/json-schema", "version": "2.6.0", diff --git a/src/Mcp/Tool/Project/CreateProjectTool.php b/src/Mcp/Tool/Project/CreateProjectTool.php new file mode 100644 index 0000000..75001c4 --- /dev/null +++ b/src/Mcp/Tool/Project/CreateProjectTool.php @@ -0,0 +1,64 @@ +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(), + ]); + } +} diff --git a/src/Mcp/Tool/Project/GetProjectTool.php b/src/Mcp/Tool/Project/GetProjectTool.php new file mode 100644 index 0000000..c886efc --- /dev/null +++ b/src/Mcp/Tool/Project/GetProjectTool.php @@ -0,0 +1,63 @@ +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, + ]); + } +} diff --git a/src/Mcp/Tool/Project/ListProjectsTool.php b/src/Mcp/Tool/Project/ListProjectsTool.php new file mode 100644 index 0000000..eae89ca --- /dev/null +++ b/src/Mcp/Tool/Project/ListProjectsTool.php @@ -0,0 +1,34 @@ +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)); + } +} diff --git a/src/Mcp/Tool/Project/UpdateProjectTool.php b/src/Mcp/Tool/Project/UpdateProjectTool.php new file mode 100644 index 0000000..ee08b96 --- /dev/null +++ b/src/Mcp/Tool/Project/UpdateProjectTool.php @@ -0,0 +1,77 @@ +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(), + ]); + } +} diff --git a/src/Mcp/Tool/Reference/ListClientsTool.php b/src/Mcp/Tool/Reference/ListClientsTool.php new file mode 100644 index 0000000..8c29a65 --- /dev/null +++ b/src/Mcp/Tool/Reference/ListClientsTool.php @@ -0,0 +1,27 @@ +clientRepository->findBy([], ['name' => 'ASC']); + + return json_encode(array_map(fn ($client) => [ + 'id' => $client->getId(), + 'name' => $client->getName(), + 'email' => $client->getEmail(), + ], $clients)); + } +} diff --git a/src/Mcp/Tool/Reference/ListUsersTool.php b/src/Mcp/Tool/Reference/ListUsersTool.php new file mode 100644 index 0000000..0115935 --- /dev/null +++ b/src/Mcp/Tool/Reference/ListUsersTool.php @@ -0,0 +1,26 @@ +userRepository->findBy([], ['username' => 'ASC']); + + return json_encode(array_map(fn ($user) => [ + 'id' => $user->getId(), + 'username' => $user->getUsername(), + ], $users)); + } +} diff --git a/src/Mcp/Tool/Task/CreateTaskTool.php b/src/Mcp/Tool/Task/CreateTaskTool.php new file mode 100644 index 0000000..11093cb --- /dev/null +++ b/src/Mcp/Tool/Task/CreateTaskTool.php @@ -0,0 +1,148 @@ +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(), + ]); + } +} diff --git a/src/Mcp/Tool/Task/DeleteTaskTool.php b/src/Mcp/Tool/Task/DeleteTaskTool.php new file mode 100644 index 0000000..788d972 --- /dev/null +++ b/src/Mcp/Tool/Task/DeleteTaskTool.php @@ -0,0 +1,39 @@ +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), + ]); + } +} diff --git a/src/Mcp/Tool/Task/GetTaskTool.php b/src/Mcp/Tool/Task/GetTaskTool.php new file mode 100644 index 0000000..f214f3f --- /dev/null +++ b/src/Mcp/Tool/Task/GetTaskTool.php @@ -0,0 +1,81 @@ +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(), + ]); + } +} diff --git a/src/Mcp/Tool/Task/ListTasksTool.php b/src/Mcp/Tool/Task/ListTasksTool.php new file mode 100644 index 0000000..bc914d6 --- /dev/null +++ b/src/Mcp/Tool/Task/ListTasksTool.php @@ -0,0 +1,107 @@ +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))); + } +} diff --git a/src/Mcp/Tool/Task/UpdateTaskTool.php b/src/Mcp/Tool/Task/UpdateTaskTool.php new file mode 100644 index 0000000..96db18f --- /dev/null +++ b/src/Mcp/Tool/Task/UpdateTaskTool.php @@ -0,0 +1,151 @@ +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(), + ]); + } +} diff --git a/src/Mcp/Tool/TaskMeta/CreateGroupTool.php b/src/Mcp/Tool/TaskMeta/CreateGroupTool.php new file mode 100644 index 0000000..a98a91e --- /dev/null +++ b/src/Mcp/Tool/TaskMeta/CreateGroupTool.php @@ -0,0 +1,61 @@ +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(), + ]); + } +} diff --git a/src/Mcp/Tool/TaskMeta/ListEffortsTool.php b/src/Mcp/Tool/TaskMeta/ListEffortsTool.php new file mode 100644 index 0000000..1dbd4ad --- /dev/null +++ b/src/Mcp/Tool/TaskMeta/ListEffortsTool.php @@ -0,0 +1,26 @@ +taskEffortRepository->findBy([], ['label' => 'ASC']); + + return json_encode(array_map(fn ($e) => [ + 'id' => $e->getId(), + 'label' => $e->getLabel(), + ], $efforts)); + } +} diff --git a/src/Mcp/Tool/TaskMeta/ListGroupsTool.php b/src/Mcp/Tool/TaskMeta/ListGroupsTool.php new file mode 100644 index 0000000..35cddf7 --- /dev/null +++ b/src/Mcp/Tool/TaskMeta/ListGroupsTool.php @@ -0,0 +1,39 @@ + $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)); + } +} diff --git a/src/Mcp/Tool/TaskMeta/ListPrioritiesTool.php b/src/Mcp/Tool/TaskMeta/ListPrioritiesTool.php new file mode 100644 index 0000000..53f3dba --- /dev/null +++ b/src/Mcp/Tool/TaskMeta/ListPrioritiesTool.php @@ -0,0 +1,27 @@ +taskPriorityRepository->findBy([], ['label' => 'ASC']); + + return json_encode(array_map(fn ($p) => [ + 'id' => $p->getId(), + 'label' => $p->getLabel(), + 'color' => $p->getColor(), + ], $priorities)); + } +} diff --git a/src/Mcp/Tool/TaskMeta/ListStatusesTool.php b/src/Mcp/Tool/TaskMeta/ListStatusesTool.php new file mode 100644 index 0000000..9f09597 --- /dev/null +++ b/src/Mcp/Tool/TaskMeta/ListStatusesTool.php @@ -0,0 +1,29 @@ +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)); + } +} diff --git a/src/Mcp/Tool/TaskMeta/ListTagsTool.php b/src/Mcp/Tool/TaskMeta/ListTagsTool.php new file mode 100644 index 0000000..b91f271 --- /dev/null +++ b/src/Mcp/Tool/TaskMeta/ListTagsTool.php @@ -0,0 +1,27 @@ +taskTagRepository->findBy([], ['label' => 'ASC']); + + return json_encode(array_map(fn ($t) => [ + 'id' => $t->getId(), + 'label' => $t->getLabel(), + 'color' => $t->getColor(), + ], $tags)); + } +} diff --git a/src/Mcp/Tool/TaskMeta/UpdateGroupTool.php b/src/Mcp/Tool/TaskMeta/UpdateGroupTool.php new file mode 100644 index 0000000..3b818d8 --- /dev/null +++ b/src/Mcp/Tool/TaskMeta/UpdateGroupTool.php @@ -0,0 +1,63 @@ +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(), + ]); + } +} diff --git a/src/Mcp/Tool/TimeEntry/CreateTimeEntryTool.php b/src/Mcp/Tool/TimeEntry/CreateTimeEntryTool.php new file mode 100644 index 0000000..75642c9 --- /dev/null +++ b/src/Mcp/Tool/TimeEntry/CreateTimeEntryTool.php @@ -0,0 +1,121 @@ +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(), + ]); + } +} diff --git a/src/Mcp/Tool/TimeEntry/DeleteTimeEntryTool.php b/src/Mcp/Tool/TimeEntry/DeleteTimeEntryTool.php new file mode 100644 index 0000000..2581b12 --- /dev/null +++ b/src/Mcp/Tool/TimeEntry/DeleteTimeEntryTool.php @@ -0,0 +1,38 @@ +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.', + ]); + } +} diff --git a/src/Mcp/Tool/TimeEntry/ListTimeEntriesTool.php b/src/Mcp/Tool/TimeEntry/ListTimeEntriesTool.php new file mode 100644 index 0000000..77435c2 --- /dev/null +++ b/src/Mcp/Tool/TimeEntry/ListTimeEntriesTool.php @@ -0,0 +1,88 @@ +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)); + } +} diff --git a/src/Mcp/Tool/TimeEntry/UpdateTimeEntryTool.php b/src/Mcp/Tool/TimeEntry/UpdateTimeEntryTool.php new file mode 100644 index 0000000..2bd514d --- /dev/null +++ b/src/Mcp/Tool/TimeEntry/UpdateTimeEntryTool.php @@ -0,0 +1,111 @@ +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(), + ]); + } +}