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) <noreply@anthropic.com>
This commit is contained in:
@@ -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.*",
|
||||
|
||||
80
composer.lock
generated
80
composer.lock
generated
@@ -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",
|
||||
|
||||
64
src/Mcp/Tool/Project/CreateProjectTool.php
Normal file
64
src/Mcp/Tool/Project/CreateProjectTool.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Project;
|
||||
|
||||
use App\Entity\Project;
|
||||
use App\Repository\ClientRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'create-project', description: 'Create a new project. Code must be 2-10 uppercase letters.')]
|
||||
class CreateProjectTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly ClientRepository $clientRepository,
|
||||
) {}
|
||||
|
||||
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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
63
src/Mcp/Tool/Project/GetProjectTool.php
Normal file
63
src/Mcp/Tool/Project/GetProjectTool.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Project;
|
||||
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\Repository\TaskRepository;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'get-project', description: 'Get project details with task count summary per status')]
|
||||
class GetProjectTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProjectRepository $projectRepository,
|
||||
private readonly TaskRepository $taskRepository,
|
||||
) {}
|
||||
|
||||
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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
34
src/Mcp/Tool/Project/ListProjectsTool.php
Normal file
34
src/Mcp/Tool/Project/ListProjectsTool.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Project;
|
||||
|
||||
use App\Repository\ProjectRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[McpTool(name: 'list-projects', description: 'List all projects with optional archive filter')]
|
||||
class ListProjectsTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProjectRepository $projectRepository,
|
||||
) {}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
77
src/Mcp/Tool/Project/UpdateProjectTool.php
Normal file
77
src/Mcp/Tool/Project/UpdateProjectTool.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Project;
|
||||
|
||||
use App\Repository\ClientRepository;
|
||||
use App\Repository\ProjectRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'update-project', description: 'Update an existing project. Only provided fields are changed.')]
|
||||
class UpdateProjectTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProjectRepository $projectRepository,
|
||||
private readonly ClientRepository $clientRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {}
|
||||
|
||||
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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
27
src/Mcp/Tool/Reference/ListClientsTool.php
Normal file
27
src/Mcp/Tool/Reference/ListClientsTool.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Reference;
|
||||
|
||||
use App\Repository\ClientRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[McpTool(name: 'list-clients', description: 'List all clients with their IDs, names, and emails. Use this to discover valid client IDs for project parameters.')]
|
||||
class ListClientsTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ClientRepository $clientRepository,
|
||||
) {}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
26
src/Mcp/Tool/Reference/ListUsersTool.php
Normal file
26
src/Mcp/Tool/Reference/ListUsersTool.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Reference;
|
||||
|
||||
use App\Repository\UserRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[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.')]
|
||||
class ListUsersTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserRepository $userRepository,
|
||||
) {}
|
||||
|
||||
public function __invoke(): string
|
||||
{
|
||||
$users = $this->userRepository->findBy([], ['username' => 'ASC']);
|
||||
|
||||
return json_encode(array_map(fn ($user) => [
|
||||
'id' => $user->getId(),
|
||||
'username' => $user->getUsername(),
|
||||
], $users));
|
||||
}
|
||||
}
|
||||
148
src/Mcp/Tool/Task/CreateTaskTool.php
Normal file
148
src/Mcp/Tool/Task/CreateTaskTool.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?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 InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[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.')]
|
||||
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,
|
||||
) {}
|
||||
|
||||
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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
39
src/Mcp/Tool/Task/DeleteTaskTool.php
Normal file
39
src/Mcp/Tool/Task/DeleteTaskTool.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Task;
|
||||
|
||||
use App\Repository\TaskRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'delete-task', description: 'Delete a task permanently. This also deletes all associated documents.')]
|
||||
class DeleteTaskTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TaskRepository $taskRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {}
|
||||
|
||||
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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
81
src/Mcp/Tool/Task/GetTaskTool.php
Normal file
81
src/Mcp/Tool/Task/GetTaskTool.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Task;
|
||||
|
||||
use App\Repository\TaskRepository;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'get-task', description: 'Get full task details including description, all relations, and documents')]
|
||||
class GetTaskTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TaskRepository $taskRepository,
|
||||
) {}
|
||||
|
||||
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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
107
src/Mcp/Tool/Task/ListTasksTool.php
Normal file
107
src/Mcp/Tool/Task/ListTasksTool.php
Normal file
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Task;
|
||||
|
||||
use App\Repository\TaskRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[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.')]
|
||||
class ListTasksTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TaskRepository $taskRepository,
|
||||
) {}
|
||||
|
||||
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)));
|
||||
}
|
||||
}
|
||||
151
src/Mcp/Tool/Task/UpdateTaskTool.php
Normal file
151
src/Mcp/Tool/Task/UpdateTaskTool.php
Normal file
@@ -0,0 +1,151 @@
|
||||
<?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 InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'update-task', description: 'Update an existing task. Only provided fields are changed. Use list-statuses, list-priorities, etc. to discover valid IDs.')]
|
||||
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,
|
||||
) {}
|
||||
|
||||
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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
61
src/Mcp/Tool/TaskMeta/CreateGroupTool.php
Normal file
61
src/Mcp/Tool/TaskMeta/CreateGroupTool.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\TaskMeta;
|
||||
|
||||
use App\Entity\TaskGroup;
|
||||
use App\Repository\ProjectRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'create-group', description: 'Create a new task group for a project')]
|
||||
class CreateGroupTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly ProjectRepository $projectRepository,
|
||||
) {}
|
||||
|
||||
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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
26
src/Mcp/Tool/TaskMeta/ListEffortsTool.php
Normal file
26
src/Mcp/Tool/TaskMeta/ListEffortsTool.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\TaskMeta;
|
||||
|
||||
use App\Repository\TaskEffortRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[McpTool(name: 'list-efforts', description: 'List all task effort levels. Efforts are global (shared across all projects).')]
|
||||
class ListEffortsTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TaskEffortRepository $taskEffortRepository,
|
||||
) {}
|
||||
|
||||
public function __invoke(): string
|
||||
{
|
||||
$efforts = $this->taskEffortRepository->findBy([], ['label' => 'ASC']);
|
||||
|
||||
return json_encode(array_map(fn ($e) => [
|
||||
'id' => $e->getId(),
|
||||
'label' => $e->getLabel(),
|
||||
], $efforts));
|
||||
}
|
||||
}
|
||||
39
src/Mcp/Tool/TaskMeta/ListGroupsTool.php
Normal file
39
src/Mcp/Tool/TaskMeta/ListGroupsTool.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\TaskMeta;
|
||||
|
||||
use App\Repository\TaskGroupRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[McpTool(name: 'list-groups', description: 'List task groups, optionally filtered by project. Groups are per-project (each group belongs to one project).')]
|
||||
class ListGroupsTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TaskGroupRepository $taskGroupRepository,
|
||||
) {}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
27
src/Mcp/Tool/TaskMeta/ListPrioritiesTool.php
Normal file
27
src/Mcp/Tool/TaskMeta/ListPrioritiesTool.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\TaskMeta;
|
||||
|
||||
use App\Repository\TaskPriorityRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[McpTool(name: 'list-priorities', description: 'List all task priorities. Priorities are global (shared across all projects).')]
|
||||
class ListPrioritiesTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TaskPriorityRepository $taskPriorityRepository,
|
||||
) {}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
29
src/Mcp/Tool/TaskMeta/ListStatusesTool.php
Normal file
29
src/Mcp/Tool/TaskMeta/ListStatusesTool.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\TaskMeta;
|
||||
|
||||
use App\Repository\TaskStatusRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[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.')]
|
||||
class ListStatusesTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TaskStatusRepository $taskStatusRepository,
|
||||
) {}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
27
src/Mcp/Tool/TaskMeta/ListTagsTool.php
Normal file
27
src/Mcp/Tool/TaskMeta/ListTagsTool.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\TaskMeta;
|
||||
|
||||
use App\Repository\TaskTagRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[McpTool(name: 'list-tags', description: 'List all task tags. Tags are global (shared across all projects).')]
|
||||
class ListTagsTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TaskTagRepository $taskTagRepository,
|
||||
) {}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
63
src/Mcp/Tool/TaskMeta/UpdateGroupTool.php
Normal file
63
src/Mcp/Tool/TaskMeta/UpdateGroupTool.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\TaskMeta;
|
||||
|
||||
use App\Repository\TaskGroupRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'update-group', description: 'Update an existing task group. Only provided fields are changed.')]
|
||||
class UpdateGroupTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TaskGroupRepository $taskGroupRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {}
|
||||
|
||||
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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
121
src/Mcp/Tool/TimeEntry/CreateTimeEntryTool.php
Normal file
121
src/Mcp/Tool/TimeEntry/CreateTimeEntryTool.php
Normal file
@@ -0,0 +1,121 @@
|
||||
<?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 DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[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.')]
|
||||
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,
|
||||
) {}
|
||||
|
||||
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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
38
src/Mcp/Tool/TimeEntry/DeleteTimeEntryTool.php
Normal file
38
src/Mcp/Tool/TimeEntry/DeleteTimeEntryTool.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\TimeEntry;
|
||||
|
||||
use App\Repository\TimeEntryRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'delete-time-entry', description: 'Delete a time entry permanently')]
|
||||
class DeleteTimeEntryTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TimeEntryRepository $timeEntryRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {}
|
||||
|
||||
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.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
88
src/Mcp/Tool/TimeEntry/ListTimeEntriesTool.php
Normal file
88
src/Mcp/Tool/TimeEntry/ListTimeEntriesTool.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\TimeEntry;
|
||||
|
||||
use App\Repository\TimeEntryRepository;
|
||||
use DateTimeImmutable;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[McpTool(name: 'list-time-entries', description: 'List time entries with optional filters. Duration is computed in minutes and null for active timers.')]
|
||||
class ListTimeEntriesTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TimeEntryRepository $timeEntryRepository,
|
||||
) {}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
111
src/Mcp/Tool/TimeEntry/UpdateTimeEntryTool.php
Normal file
111
src/Mcp/Tool/TimeEntry/UpdateTimeEntryTool.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?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 DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[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.')]
|
||||
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,
|
||||
) {}
|
||||
|
||||
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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user