Fix getIsFinal() method name, enrich create/update tool return formats to match get/list consistency, fix duplicate Reference section in spec, correct tool count to 22. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2177 lines
68 KiB
Markdown
2177 lines
68 KiB
Markdown
# MCP Server Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Add an MCP server to Lesstime exposing projects, tasks, and time tracking for AI clients via STDIO and HTTP transports.
|
|
|
|
**Architecture:** Install `symfony/mcp-bundle`, create 22 tool classes in `src/Mcp/Tool/` organized by domain (Project, Task, TaskMeta, TimeEntry, Reference). HTTP transport secured by API token on User entity with a custom Symfony authenticator. STDIO for local Claude Code usage.
|
|
|
|
**Tech Stack:** symfony/mcp-bundle, Symfony 8 security (custom authenticator), Doctrine ORM, PHP 8.4
|
|
|
|
**Spec:** `docs/superpowers/specs/2026-03-15-mcp-server-design.md`
|
|
|
|
---
|
|
|
|
## Chunk 1: Infrastructure (Bundle, Auth, Config)
|
|
|
|
### Task 1: Install symfony/mcp-bundle
|
|
|
|
**Files:**
|
|
- Modify: `composer.json`
|
|
- Create: `config/packages/mcp.yaml`
|
|
- Create: `config/routes/mcp.yaml`
|
|
|
|
- [ ] **Step 1: Install the bundle via Composer**
|
|
|
|
Run inside Docker container:
|
|
```bash
|
|
docker exec -u www-data php-lesstime-fpm composer require symfony/mcp-bundle
|
|
```
|
|
|
|
- [ ] **Step 2: Create MCP config**
|
|
|
|
Create `config/packages/mcp.yaml`:
|
|
```yaml
|
|
mcp:
|
|
app: 'lesstime'
|
|
version: '1.0.0'
|
|
description: 'Lesstime project management — projects, tasks, time tracking'
|
|
instructions: |
|
|
This server provides access to the Lesstime project management system.
|
|
You can list/create/update/delete projects, tasks, and time entries.
|
|
Tasks belong to projects and have statuses, priorities, efforts, tags, and groups.
|
|
Statuses, priorities, efforts, and tags are GLOBAL (shared across all projects).
|
|
Groups are PER-PROJECT (each group belongs to one project).
|
|
Time entries track work duration and can be linked to projects and tasks.
|
|
Use list-statuses, list-priorities, list-efforts, list-tags, list-groups to discover
|
|
available metadata before creating or updating tasks.
|
|
Use list-users and list-clients to discover valid user and client IDs.
|
|
client_transports:
|
|
stdio: true
|
|
http: true
|
|
http:
|
|
path: /_mcp
|
|
session:
|
|
store: file
|
|
directory: '%kernel.cache_dir%/mcp-sessions'
|
|
ttl: 3600
|
|
```
|
|
|
|
- [ ] **Step 3: Create MCP route config**
|
|
|
|
Create `config/routes/mcp.yaml`:
|
|
```yaml
|
|
mcp:
|
|
resource: .
|
|
type: mcp
|
|
```
|
|
|
|
- [ ] **Step 4: Verify the bundle is loaded**
|
|
|
|
```bash
|
|
docker exec -u www-data php-lesstime-fpm php bin/console mcp:server --help
|
|
```
|
|
Expected: Help output for the `mcp:server` command (no errors).
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add composer.json composer.lock symfony.lock config/packages/mcp.yaml config/routes/mcp.yaml
|
|
git commit -m "feat : install symfony/mcp-bundle with STDIO + HTTP transport config"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Add API token to User entity
|
|
|
|
**Files:**
|
|
- Modify: `src/Entity/User.php`
|
|
- Create: new Doctrine migration
|
|
|
|
- [ ] **Step 1: Add apiToken property to User entity**
|
|
|
|
In `src/Entity/User.php`, add after the `$createdAt` property:
|
|
|
|
```php
|
|
#[ORM\Column(length: 64, unique: true, nullable: true)]
|
|
private ?string $apiToken = null;
|
|
```
|
|
|
|
Add getter and setter after the `eraseCredentials` method:
|
|
|
|
```php
|
|
public function getApiToken(): ?string
|
|
{
|
|
return $this->apiToken;
|
|
}
|
|
|
|
public function setApiToken(?string $apiToken): static
|
|
{
|
|
$this->apiToken = $apiToken;
|
|
|
|
return $this;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Generate and run the migration**
|
|
|
|
```bash
|
|
docker exec -u www-data php-lesstime-fpm php bin/console doctrine:migrations:diff
|
|
docker exec -u www-data php-lesstime-fpm php bin/console doctrine:migrations:migrate --no-interaction
|
|
```
|
|
|
|
Expected: Migration runs successfully, adds `api_token` column to `user` table.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/Entity/User.php migrations/
|
|
git commit -m "feat : add apiToken field to User entity for MCP HTTP auth"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Create API token authenticator
|
|
|
|
**Files:**
|
|
- Create: `src/Security/ApiTokenAuthenticator.php`
|
|
- Modify: `config/packages/security.yaml`
|
|
|
|
- [ ] **Step 1: Create the authenticator class**
|
|
|
|
Create `src/Security/ApiTokenAuthenticator.php`:
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Security;
|
|
|
|
use App\Repository\UserRepository;
|
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
|
use Symfony\Component\HttpFoundation\Request;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
|
use Symfony\Component\Security\Core\Exception\AuthenticationException;
|
|
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
|
|
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
|
|
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
|
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
|
|
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
|
|
|
|
class ApiTokenAuthenticator extends AbstractAuthenticator
|
|
{
|
|
public function __construct(
|
|
private readonly UserRepository $userRepository,
|
|
) {
|
|
}
|
|
|
|
public function supports(Request $request): ?bool
|
|
{
|
|
return $request->headers->has('Authorization')
|
|
&& str_starts_with((string) $request->headers->get('Authorization'), 'Bearer ');
|
|
}
|
|
|
|
public function authenticate(Request $request): Passport
|
|
{
|
|
$authHeader = (string) $request->headers->get('Authorization');
|
|
$token = substr($authHeader, 7); // Remove "Bearer " prefix
|
|
|
|
if ('' === $token) {
|
|
throw new CustomUserMessageAuthenticationException('API token missing.');
|
|
}
|
|
|
|
return new SelfValidatingPassport(
|
|
new UserBadge($token, function (string $token): ?\App\Entity\User {
|
|
$user = $this->userRepository->findOneBy(['apiToken' => $token]);
|
|
|
|
if (null === $user) {
|
|
throw new CustomUserMessageAuthenticationException('Invalid API token.');
|
|
}
|
|
|
|
return $user;
|
|
})
|
|
);
|
|
}
|
|
|
|
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
|
|
{
|
|
return null; // Let the request continue
|
|
}
|
|
|
|
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
|
|
{
|
|
return new JsonResponse(
|
|
['error' => $exception->getMessageKey()],
|
|
Response::HTTP_UNAUTHORIZED
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Add MCP firewall to security.yaml**
|
|
|
|
In `config/packages/security.yaml`, add the `mcp` firewall **before** the `api` firewall:
|
|
|
|
```yaml
|
|
mcp:
|
|
pattern: ^/_mcp
|
|
stateless: true
|
|
provider: app_user_provider
|
|
custom_authenticators:
|
|
- App\Security\ApiTokenAuthenticator
|
|
```
|
|
|
|
Add access control rule **before** the existing `/api` rules:
|
|
|
|
```yaml
|
|
- { path: ^/_mcp, roles: IS_AUTHENTICATED_FULLY }
|
|
```
|
|
|
|
- [ ] **Step 3: Verify the firewall is registered**
|
|
|
|
```bash
|
|
docker exec -u www-data php-lesstime-fpm php bin/console debug:firewall
|
|
```
|
|
|
|
Expected: `mcp` firewall appears in the list.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/Security/ApiTokenAuthenticator.php config/packages/security.yaml
|
|
git commit -m "feat : add ApiTokenAuthenticator for MCP HTTP transport"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Create generate-api-token command
|
|
|
|
**Files:**
|
|
- Create: `src/Command/GenerateApiTokenCommand.php`
|
|
|
|
- [ ] **Step 1: Create the console command**
|
|
|
|
Create `src/Command/GenerateApiTokenCommand.php`:
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Command;
|
|
|
|
use App\Repository\UserRepository;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use Symfony\Component\Console\Attribute\AsCommand;
|
|
use Symfony\Component\Console\Command\Command;
|
|
use Symfony\Component\Console\Input\InputArgument;
|
|
use Symfony\Component\Console\Input\InputInterface;
|
|
use Symfony\Component\Console\Output\OutputInterface;
|
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
|
|
|
#[AsCommand(
|
|
name: 'app:generate-api-token',
|
|
description: 'Generate or regenerate an API token for a user (used for MCP HTTP authentication)',
|
|
)]
|
|
class GenerateApiTokenCommand extends Command
|
|
{
|
|
public function __construct(
|
|
private readonly UserRepository $userRepository,
|
|
private readonly EntityManagerInterface $entityManager,
|
|
) {
|
|
parent::__construct();
|
|
}
|
|
|
|
protected function configure(): void
|
|
{
|
|
$this->addArgument('username', InputArgument::REQUIRED, 'The username to generate a token for');
|
|
}
|
|
|
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
|
{
|
|
$io = new SymfonyStyle($input, $output);
|
|
$username = $input->getArgument('username');
|
|
|
|
$user = $this->userRepository->findOneBy(['username' => $username]);
|
|
|
|
if (null === $user) {
|
|
$io->error(\sprintf('User "%s" not found.', $username));
|
|
|
|
return Command::FAILURE;
|
|
}
|
|
|
|
$token = bin2hex(random_bytes(32));
|
|
$user->setApiToken($token);
|
|
$this->entityManager->flush();
|
|
|
|
$io->success(\sprintf('API token generated for user "%s":', $username));
|
|
$io->writeln($token);
|
|
|
|
return Command::SUCCESS;
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Verify the command works**
|
|
|
|
```bash
|
|
docker exec -u www-data php-lesstime-fpm php bin/console app:generate-api-token admin
|
|
```
|
|
|
|
Expected: Outputs a 64-character hex token.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/Command/GenerateApiTokenCommand.php
|
|
git commit -m "feat : add app:generate-api-token console command"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Add Nginx location and update fixtures
|
|
|
|
**Files:**
|
|
- Modify: `docker/nginx/conf.d/lesstime.conf`
|
|
- Modify: `src/DataFixtures/AppFixtures.php`
|
|
|
|
- [ ] **Step 1: Add Nginx location block for /_mcp**
|
|
|
|
In `docker/nginx/conf.d/lesstime.conf`, add this block **before** the `location ^~ /api/` block:
|
|
|
|
```nginx
|
|
location ^~ /_mcp {
|
|
root /var/www/html/public;
|
|
try_files $uri /index.php?$query_string;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Add API token to admin fixture**
|
|
|
|
In `src/DataFixtures/AppFixtures.php`, in the section where the admin user is created, add after `setPassword(...)`:
|
|
|
|
```php
|
|
->setApiToken('dev-mcp-token-for-testing-only-do-not-use-in-production')
|
|
```
|
|
|
|
- [ ] **Step 3: Restart Nginx and reload fixtures**
|
|
|
|
```bash
|
|
docker restart nginx-lesstime
|
|
docker exec -u www-data php-lesstime-fpm php bin/console doctrine:fixtures:load --no-interaction
|
|
```
|
|
|
|
- [ ] **Step 4: Test HTTP transport with curl**
|
|
|
|
```bash
|
|
curl -s -X POST http://localhost:8082/_mcp \
|
|
-H "Content-Type: application/json" \
|
|
-H "Authorization: Bearer dev-mcp-token-for-testing-only-do-not-use-in-production" \
|
|
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'
|
|
```
|
|
|
|
Expected: JSON-RPC response with server capabilities (or at least not a 401/404).
|
|
|
|
- [ ] **Step 5: Test STDIO transport**
|
|
|
|
```bash
|
|
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' | docker exec -i php-lesstime-fpm php bin/console mcp:server
|
|
```
|
|
|
|
Expected: JSON-RPC response via stdout.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add docker/nginx/conf.d/lesstime.conf src/DataFixtures/AppFixtures.php
|
|
git commit -m "feat : add Nginx /_mcp location and API token fixture for MCP"
|
|
```
|
|
|
|
---
|
|
|
|
## Chunk 2: Reference & Project Tools
|
|
|
|
### Task 6: Reference tools (list-users, list-clients)
|
|
|
|
**Files:**
|
|
- Create: `src/Mcp/Tool/Reference/ListUsersTool.php`
|
|
- Create: `src/Mcp/Tool/Reference/ListClientsTool.php`
|
|
|
|
- [ ] **Step 1: Create ListUsersTool**
|
|
|
|
Create `src/Mcp/Tool/Reference/ListUsersTool.php`:
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Mcp\Tool\Reference;
|
|
|
|
use App\Repository\UserRepository;
|
|
use Mcp\Capability\Attribute\McpTool;
|
|
|
|
class ListUsersTool
|
|
{
|
|
public function __construct(
|
|
private readonly UserRepository $userRepository,
|
|
) {
|
|
}
|
|
|
|
#[McpTool(name: 'list-users', description: 'List all users with their IDs and usernames. Use this to discover valid user IDs for assignee or time entry parameters.')]
|
|
public function __invoke(): string
|
|
{
|
|
$users = $this->userRepository->findBy([], ['username' => 'ASC']);
|
|
|
|
return json_encode(array_map(fn($user) => [
|
|
'id' => $user->getId(),
|
|
'username' => $user->getUsername(),
|
|
], $users));
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create ListClientsTool**
|
|
|
|
Create `src/Mcp/Tool/Reference/ListClientsTool.php`:
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Mcp\Tool\Reference;
|
|
|
|
use App\Repository\ClientRepository;
|
|
use Mcp\Capability\Attribute\McpTool;
|
|
|
|
class ListClientsTool
|
|
{
|
|
public function __construct(
|
|
private readonly ClientRepository $clientRepository,
|
|
) {
|
|
}
|
|
|
|
#[McpTool(name: 'list-clients', description: 'List all clients with their IDs, names, and emails. Use this to discover valid client IDs for project parameters.')]
|
|
public function __invoke(): string
|
|
{
|
|
$clients = $this->clientRepository->findBy([], ['name' => 'ASC']);
|
|
|
|
return json_encode(array_map(fn($client) => [
|
|
'id' => $client->getId(),
|
|
'name' => $client->getName(),
|
|
'email' => $client->getEmail(),
|
|
], $clients));
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Verify tools are discovered**
|
|
|
|
```bash
|
|
docker exec -u www-data php-lesstime-fpm php bin/console debug:container --tag=mcp.tool 2>/dev/null || docker exec -u www-data php-lesstime-fpm php bin/console mcp:server --help
|
|
```
|
|
|
|
Check that both tools appear in the registered MCP tools.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/Mcp/Tool/Reference/
|
|
git commit -m "feat : add list-users and list-clients MCP tools"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Project tools (list, get, create, update)
|
|
|
|
**Files:**
|
|
- Create: `src/Mcp/Tool/Project/ListProjectsTool.php`
|
|
- Create: `src/Mcp/Tool/Project/GetProjectTool.php`
|
|
- Create: `src/Mcp/Tool/Project/CreateProjectTool.php`
|
|
- Create: `src/Mcp/Tool/Project/UpdateProjectTool.php`
|
|
|
|
- [ ] **Step 1: Create ListProjectsTool**
|
|
|
|
Create `src/Mcp/Tool/Project/ListProjectsTool.php`:
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Mcp\Tool\Project;
|
|
|
|
use App\Repository\ProjectRepository;
|
|
use Mcp\Capability\Attribute\McpTool;
|
|
|
|
class ListProjectsTool
|
|
{
|
|
public function __construct(
|
|
private readonly ProjectRepository $projectRepository,
|
|
) {
|
|
}
|
|
|
|
#[McpTool(name: 'list-projects', description: 'List all projects with optional archive filter')]
|
|
public function __invoke(bool $archived = false): string
|
|
{
|
|
$projects = $this->projectRepository->findBy(['archived' => $archived], ['name' => 'ASC']);
|
|
|
|
return json_encode(array_map(fn($project) => [
|
|
'id' => $project->getId(),
|
|
'code' => $project->getCode(),
|
|
'name' => $project->getName(),
|
|
'description' => $project->getDescription(),
|
|
'color' => $project->getColor(),
|
|
'client' => $project->getClient() ? [
|
|
'id' => $project->getClient()->getId(),
|
|
'name' => $project->getClient()->getName(),
|
|
] : null,
|
|
'archived' => $project->isArchived(),
|
|
], $projects));
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create GetProjectTool**
|
|
|
|
Create `src/Mcp/Tool/Project/GetProjectTool.php`:
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Mcp\Tool\Project;
|
|
|
|
use App\Repository\ProjectRepository;
|
|
use App\Repository\TaskRepository;
|
|
use Mcp\Capability\Attribute\McpTool;
|
|
|
|
class GetProjectTool
|
|
{
|
|
public function __construct(
|
|
private readonly ProjectRepository $projectRepository,
|
|
private readonly TaskRepository $taskRepository,
|
|
) {
|
|
}
|
|
|
|
#[McpTool(name: 'get-project', description: 'Get project details with task count summary per status')]
|
|
public function __invoke(int $id): string
|
|
{
|
|
$project = $this->projectRepository->find($id);
|
|
|
|
if (null === $project) {
|
|
throw new \InvalidArgumentException(\sprintf('Project with ID %d not found.', $id));
|
|
}
|
|
|
|
// Count tasks per status
|
|
$qb = $this->taskRepository->createQueryBuilder('t')
|
|
->select('s.label AS statusLabel, COUNT(t.id) AS taskCount')
|
|
->leftJoin('t.status', 's')
|
|
->where('t.project = :project')
|
|
->setParameter('project', $project)
|
|
->groupBy('s.id, s.label');
|
|
|
|
$statusCounts = [];
|
|
$totalTasks = 0;
|
|
foreach ($qb->getQuery()->getResult() as $row) {
|
|
$label = $row['statusLabel'] ?? 'No status';
|
|
$count = (int) $row['taskCount'];
|
|
$statusCounts[$label] = $count;
|
|
$totalTasks += $count;
|
|
}
|
|
|
|
return json_encode([
|
|
'id' => $project->getId(),
|
|
'code' => $project->getCode(),
|
|
'name' => $project->getName(),
|
|
'description' => $project->getDescription(),
|
|
'color' => $project->getColor(),
|
|
'client' => $project->getClient() ? [
|
|
'id' => $project->getClient()->getId(),
|
|
'name' => $project->getClient()->getName(),
|
|
] : null,
|
|
'archived' => $project->isArchived(),
|
|
'taskSummary' => $statusCounts,
|
|
'totalTasks' => $totalTasks,
|
|
]);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Create CreateProjectTool**
|
|
|
|
Create `src/Mcp/Tool/Project/CreateProjectTool.php`:
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Mcp\Tool\Project;
|
|
|
|
use App\Entity\Project;
|
|
use App\Repository\ClientRepository;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use Mcp\Capability\Attribute\McpTool;
|
|
|
|
class CreateProjectTool
|
|
{
|
|
public function __construct(
|
|
private readonly EntityManagerInterface $entityManager,
|
|
private readonly ClientRepository $clientRepository,
|
|
) {
|
|
}
|
|
|
|
#[McpTool(name: 'create-project', description: 'Create a new project. Code must be 2-10 uppercase letters.')]
|
|
public function __invoke(
|
|
string $name,
|
|
string $code,
|
|
?string $description = null,
|
|
?string $color = null,
|
|
?int $clientId = null,
|
|
): string {
|
|
$project = new Project();
|
|
$project->setName($name);
|
|
$project->setCode($code);
|
|
|
|
if (null !== $description) {
|
|
$project->setDescription($description);
|
|
}
|
|
if (null !== $color) {
|
|
$project->setColor($color);
|
|
}
|
|
if (null !== $clientId) {
|
|
$client = $this->clientRepository->find($clientId);
|
|
if (null === $client) {
|
|
throw new \InvalidArgumentException(\sprintf('Client with ID %d not found.', $clientId));
|
|
}
|
|
$project->setClient($client);
|
|
}
|
|
|
|
$this->entityManager->persist($project);
|
|
$this->entityManager->flush();
|
|
|
|
return json_encode([
|
|
'id' => $project->getId(),
|
|
'code' => $project->getCode(),
|
|
'name' => $project->getName(),
|
|
'description' => $project->getDescription(),
|
|
'color' => $project->getColor(),
|
|
'client' => $project->getClient() ? [
|
|
'id' => $project->getClient()->getId(),
|
|
'name' => $project->getClient()->getName(),
|
|
] : null,
|
|
'archived' => $project->isArchived(),
|
|
]);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Create UpdateProjectTool**
|
|
|
|
Create `src/Mcp/Tool/Project/UpdateProjectTool.php`:
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Mcp\Tool\Project;
|
|
|
|
use App\Repository\ClientRepository;
|
|
use App\Repository\ProjectRepository;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use Mcp\Capability\Attribute\McpTool;
|
|
|
|
class UpdateProjectTool
|
|
{
|
|
public function __construct(
|
|
private readonly ProjectRepository $projectRepository,
|
|
private readonly ClientRepository $clientRepository,
|
|
private readonly EntityManagerInterface $entityManager,
|
|
) {
|
|
}
|
|
|
|
#[McpTool(name: 'update-project', description: 'Update an existing project. Only provided fields are changed.')]
|
|
public function __invoke(
|
|
int $id,
|
|
?string $name = null,
|
|
?string $code = null,
|
|
?string $description = null,
|
|
?string $color = null,
|
|
?int $clientId = null,
|
|
?bool $archived = null,
|
|
): string {
|
|
$project = $this->projectRepository->find($id);
|
|
|
|
if (null === $project) {
|
|
throw new \InvalidArgumentException(\sprintf('Project with ID %d not found.', $id));
|
|
}
|
|
|
|
if (null !== $name) {
|
|
$project->setName($name);
|
|
}
|
|
if (null !== $code) {
|
|
$project->setCode($code);
|
|
}
|
|
if (null !== $description) {
|
|
$project->setDescription($description);
|
|
}
|
|
if (null !== $color) {
|
|
$project->setColor($color);
|
|
}
|
|
if (null !== $clientId) {
|
|
$client = $this->clientRepository->find($clientId);
|
|
if (null === $client) {
|
|
throw new \InvalidArgumentException(\sprintf('Client with ID %d not found.', $clientId));
|
|
}
|
|
$project->setClient($client);
|
|
}
|
|
if (null !== $archived) {
|
|
$project->setArchived($archived);
|
|
}
|
|
|
|
$this->entityManager->flush();
|
|
|
|
return json_encode([
|
|
'id' => $project->getId(),
|
|
'code' => $project->getCode(),
|
|
'name' => $project->getName(),
|
|
'description' => $project->getDescription(),
|
|
'color' => $project->getColor(),
|
|
'client' => $project->getClient() ? [
|
|
'id' => $project->getClient()->getId(),
|
|
'name' => $project->getClient()->getName(),
|
|
] : null,
|
|
'archived' => $project->isArchived(),
|
|
]);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Test with STDIO**
|
|
|
|
```bash
|
|
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | docker exec -i php-lesstime-fpm php bin/console mcp:server
|
|
```
|
|
|
|
Expected: JSON response listing all registered tools including `list-projects`, `get-project`, `create-project`, `update-project`.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add src/Mcp/Tool/Project/ src/Mcp/Tool/Reference/
|
|
git commit -m "feat : add project and reference MCP tools (list/get/create/update)"
|
|
```
|
|
|
|
---
|
|
|
|
## Chunk 3: Task Tools
|
|
|
|
### Task 8: List and get task tools
|
|
|
|
**Files:**
|
|
- Create: `src/Mcp/Tool/Task/ListTasksTool.php`
|
|
- Create: `src/Mcp/Tool/Task/GetTaskTool.php`
|
|
|
|
- [ ] **Step 1: Create ListTasksTool**
|
|
|
|
Create `src/Mcp/Tool/Task/ListTasksTool.php`:
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Mcp\Tool\Task;
|
|
|
|
use App\Repository\TaskRepository;
|
|
use Mcp\Capability\Attribute\McpTool;
|
|
|
|
class ListTasksTool
|
|
{
|
|
public function __construct(
|
|
private readonly TaskRepository $taskRepository,
|
|
) {
|
|
}
|
|
|
|
#[McpTool(name: 'list-tasks', description: 'List tasks with optional filters by project, status, assignee, priority, group, tags, and archive state. Returns max 100 results by default, use filters to narrow down.')]
|
|
public function __invoke(
|
|
?int $projectId = null,
|
|
?int $statusId = null,
|
|
?int $assigneeId = null,
|
|
?int $priorityId = null,
|
|
?int $groupId = null,
|
|
?array $tagIds = null,
|
|
bool $archived = false,
|
|
int $limit = 100,
|
|
): string {
|
|
$limit = min($limit, 200);
|
|
|
|
$qb = $this->taskRepository->createQueryBuilder('t')
|
|
->leftJoin('t.status', 's')->addSelect('s')
|
|
->leftJoin('t.priority', 'p')->addSelect('p')
|
|
->leftJoin('t.assignee', 'a')->addSelect('a')
|
|
->leftJoin('t.project', 'pr')->addSelect('pr')
|
|
->leftJoin('t.effort', 'e')->addSelect('e')
|
|
->leftJoin('t.group', 'g')->addSelect('g')
|
|
->leftJoin('t.tags', 'tg')->addSelect('tg')
|
|
->where('t.archived = :archived')
|
|
->setParameter('archived', $archived)
|
|
->orderBy('t.id', 'DESC')
|
|
->setMaxResults($limit);
|
|
|
|
if (null !== $projectId) {
|
|
$qb->andWhere('pr.id = :projectId')->setParameter('projectId', $projectId);
|
|
}
|
|
if (null !== $statusId) {
|
|
$qb->andWhere('s.id = :statusId')->setParameter('statusId', $statusId);
|
|
}
|
|
if (null !== $assigneeId) {
|
|
$qb->andWhere('a.id = :assigneeId')->setParameter('assigneeId', $assigneeId);
|
|
}
|
|
if (null !== $priorityId) {
|
|
$qb->andWhere('p.id = :priorityId')->setParameter('priorityId', $priorityId);
|
|
}
|
|
if (null !== $groupId) {
|
|
$qb->andWhere('t.group = :groupId')->setParameter('groupId', $groupId);
|
|
}
|
|
|
|
$tasks = $qb->getQuery()->getResult();
|
|
|
|
if (null !== $tagIds) {
|
|
$tasks = array_filter($tasks, function ($task) use ($tagIds) {
|
|
$taskTagIds = $task->getTags()->map(fn($t) => $t->getId())->toArray();
|
|
|
|
return !empty(array_intersect($tagIds, $taskTagIds));
|
|
});
|
|
}
|
|
|
|
return json_encode(array_map(fn($task) => [
|
|
'id' => $task->getId(),
|
|
'number' => $task->getNumber(),
|
|
'title' => $task->getTitle(),
|
|
'status' => $task->getStatus() ? [
|
|
'id' => $task->getStatus()->getId(),
|
|
'label' => $task->getStatus()->getLabel(),
|
|
'color' => $task->getStatus()->getColor(),
|
|
] : null,
|
|
'priority' => $task->getPriority() ? [
|
|
'id' => $task->getPriority()->getId(),
|
|
'label' => $task->getPriority()->getLabel(),
|
|
'color' => $task->getPriority()->getColor(),
|
|
] : null,
|
|
'assignee' => $task->getAssignee() ? [
|
|
'id' => $task->getAssignee()->getId(),
|
|
'username' => $task->getAssignee()->getUsername(),
|
|
] : null,
|
|
'effort' => $task->getEffort() ? [
|
|
'id' => $task->getEffort()->getId(),
|
|
'label' => $task->getEffort()->getLabel(),
|
|
] : null,
|
|
'group' => $task->getGroup() ? [
|
|
'id' => $task->getGroup()->getId(),
|
|
'title' => $task->getGroup()->getTitle(),
|
|
] : null,
|
|
'project' => [
|
|
'id' => $task->getProject()->getId(),
|
|
'code' => $task->getProject()->getCode(),
|
|
'name' => $task->getProject()->getName(),
|
|
],
|
|
'tags' => $task->getTags()->map(fn($t) => [
|
|
'id' => $t->getId(),
|
|
'label' => $t->getLabel(),
|
|
])->toArray(),
|
|
'archived' => $task->isArchived(),
|
|
], array_values($tasks)));
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create GetTaskTool**
|
|
|
|
Create `src/Mcp/Tool/Task/GetTaskTool.php`:
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Mcp\Tool\Task;
|
|
|
|
use App\Repository\TaskRepository;
|
|
use Mcp\Capability\Attribute\McpTool;
|
|
|
|
class GetTaskTool
|
|
{
|
|
public function __construct(
|
|
private readonly TaskRepository $taskRepository,
|
|
) {
|
|
}
|
|
|
|
#[McpTool(name: 'get-task', description: 'Get full task details including description, all relations, and documents')]
|
|
public function __invoke(int $id): string
|
|
{
|
|
$task = $this->taskRepository->find($id);
|
|
|
|
if (null === $task) {
|
|
throw new \InvalidArgumentException(\sprintf('Task with ID %d not found.', $id));
|
|
}
|
|
|
|
return json_encode([
|
|
'id' => $task->getId(),
|
|
'number' => $task->getNumber(),
|
|
'title' => $task->getTitle(),
|
|
'description' => $task->getDescription(),
|
|
'status' => $task->getStatus() ? [
|
|
'id' => $task->getStatus()->getId(),
|
|
'label' => $task->getStatus()->getLabel(),
|
|
'color' => $task->getStatus()->getColor(),
|
|
'isFinal' => $task->getStatus()->getIsFinal(),
|
|
] : null,
|
|
'priority' => $task->getPriority() ? [
|
|
'id' => $task->getPriority()->getId(),
|
|
'label' => $task->getPriority()->getLabel(),
|
|
'color' => $task->getPriority()->getColor(),
|
|
] : null,
|
|
'effort' => $task->getEffort() ? [
|
|
'id' => $task->getEffort()->getId(),
|
|
'label' => $task->getEffort()->getLabel(),
|
|
] : null,
|
|
'assignee' => $task->getAssignee() ? [
|
|
'id' => $task->getAssignee()->getId(),
|
|
'username' => $task->getAssignee()->getUsername(),
|
|
] : null,
|
|
'group' => $task->getGroup() ? [
|
|
'id' => $task->getGroup()->getId(),
|
|
'title' => $task->getGroup()->getTitle(),
|
|
'color' => $task->getGroup()->getColor(),
|
|
] : null,
|
|
'project' => [
|
|
'id' => $task->getProject()->getId(),
|
|
'code' => $task->getProject()->getCode(),
|
|
'name' => $task->getProject()->getName(),
|
|
],
|
|
'tags' => $task->getTags()->map(fn($t) => [
|
|
'id' => $t->getId(),
|
|
'label' => $t->getLabel(),
|
|
'color' => $t->getColor(),
|
|
])->toArray(),
|
|
'documents' => $task->getDocuments()->map(fn($doc) => [
|
|
'id' => $doc->getId(),
|
|
'originalName' => $doc->getOriginalName(),
|
|
'mimeType' => $doc->getMimeType(),
|
|
'size' => $doc->getSize(),
|
|
'createdAt' => $doc->getCreatedAt()?->format('c'),
|
|
'uploadedBy' => $doc->getUploadedBy() ? [
|
|
'id' => $doc->getUploadedBy()->getId(),
|
|
'username' => $doc->getUploadedBy()->getUsername(),
|
|
] : null,
|
|
])->toArray(),
|
|
'archived' => $task->isArchived(),
|
|
]);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/Mcp/Tool/Task/ListTasksTool.php src/Mcp/Tool/Task/GetTaskTool.php
|
|
git commit -m "feat : add list-tasks and get-task MCP tools"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: Create, update, delete task tools
|
|
|
|
**Files:**
|
|
- Create: `src/Mcp/Tool/Task/CreateTaskTool.php`
|
|
- Create: `src/Mcp/Tool/Task/UpdateTaskTool.php`
|
|
- Create: `src/Mcp/Tool/Task/DeleteTaskTool.php`
|
|
|
|
- [ ] **Step 1: Create CreateTaskTool**
|
|
|
|
Create `src/Mcp/Tool/Task/CreateTaskTool.php`:
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Mcp\Tool\Task;
|
|
|
|
use App\Entity\Task;
|
|
use App\Repository\ProjectRepository;
|
|
use App\Repository\TaskEffortRepository;
|
|
use App\Repository\TaskGroupRepository;
|
|
use App\Repository\TaskPriorityRepository;
|
|
use App\Repository\TaskRepository;
|
|
use App\Repository\TaskStatusRepository;
|
|
use App\Repository\TaskTagRepository;
|
|
use App\Repository\UserRepository;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use Mcp\Capability\Attribute\McpTool;
|
|
|
|
class CreateTaskTool
|
|
{
|
|
public function __construct(
|
|
private readonly EntityManagerInterface $entityManager,
|
|
private readonly ProjectRepository $projectRepository,
|
|
private readonly TaskRepository $taskRepository,
|
|
private readonly TaskStatusRepository $taskStatusRepository,
|
|
private readonly TaskPriorityRepository $taskPriorityRepository,
|
|
private readonly TaskEffortRepository $taskEffortRepository,
|
|
private readonly TaskGroupRepository $taskGroupRepository,
|
|
private readonly TaskTagRepository $taskTagRepository,
|
|
private readonly UserRepository $userRepository,
|
|
) {
|
|
}
|
|
|
|
#[McpTool(name: 'create-task', description: 'Create a new task in a project. The task number is auto-generated. Use list-statuses, list-priorities, list-efforts, list-tags, list-groups to discover valid IDs.')]
|
|
public function __invoke(
|
|
int $projectId,
|
|
string $title,
|
|
?string $description = null,
|
|
?int $statusId = null,
|
|
?int $priorityId = null,
|
|
?int $effortId = null,
|
|
?int $assigneeId = null,
|
|
?int $groupId = null,
|
|
?array $tagIds = null,
|
|
): string {
|
|
$project = $this->projectRepository->find($projectId);
|
|
if (null === $project) {
|
|
throw new \InvalidArgumentException(\sprintf('Project with ID %d not found.', $projectId));
|
|
}
|
|
|
|
$task = new Task();
|
|
$task->setProject($project);
|
|
$task->setTitle($title);
|
|
$task->setNumber($this->taskRepository->findMaxNumberByProject($project) + 1);
|
|
|
|
if (null !== $description) {
|
|
$task->setDescription($description);
|
|
}
|
|
if (null !== $statusId) {
|
|
$status = $this->taskStatusRepository->find($statusId);
|
|
if (null === $status) {
|
|
throw new \InvalidArgumentException(\sprintf('TaskStatus with ID %d not found.', $statusId));
|
|
}
|
|
$task->setStatus($status);
|
|
}
|
|
if (null !== $priorityId) {
|
|
$priority = $this->taskPriorityRepository->find($priorityId);
|
|
if (null === $priority) {
|
|
throw new \InvalidArgumentException(\sprintf('TaskPriority with ID %d not found.', $priorityId));
|
|
}
|
|
$task->setPriority($priority);
|
|
}
|
|
if (null !== $effortId) {
|
|
$effort = $this->taskEffortRepository->find($effortId);
|
|
if (null === $effort) {
|
|
throw new \InvalidArgumentException(\sprintf('TaskEffort with ID %d not found.', $effortId));
|
|
}
|
|
$task->setEffort($effort);
|
|
}
|
|
if (null !== $assigneeId) {
|
|
$assignee = $this->userRepository->find($assigneeId);
|
|
if (null === $assignee) {
|
|
throw new \InvalidArgumentException(\sprintf('User with ID %d not found.', $assigneeId));
|
|
}
|
|
$task->setAssignee($assignee);
|
|
}
|
|
if (null !== $groupId) {
|
|
$group = $this->taskGroupRepository->find($groupId);
|
|
if (null === $group) {
|
|
throw new \InvalidArgumentException(\sprintf('TaskGroup with ID %d not found.', $groupId));
|
|
}
|
|
$task->setGroup($group);
|
|
}
|
|
if (null !== $tagIds) {
|
|
foreach ($tagIds as $tagId) {
|
|
$tag = $this->taskTagRepository->find($tagId);
|
|
if (null === $tag) {
|
|
throw new \InvalidArgumentException(\sprintf('TaskTag with ID %d not found.', $tagId));
|
|
}
|
|
$task->addTag($tag);
|
|
}
|
|
}
|
|
|
|
$this->entityManager->persist($task);
|
|
$this->entityManager->flush();
|
|
|
|
return json_encode([
|
|
'id' => $task->getId(),
|
|
'number' => $task->getNumber(),
|
|
'title' => $task->getTitle(),
|
|
'description' => $task->getDescription(),
|
|
'status' => $task->getStatus() ? [
|
|
'id' => $task->getStatus()->getId(),
|
|
'label' => $task->getStatus()->getLabel(),
|
|
'color' => $task->getStatus()->getColor(),
|
|
] : null,
|
|
'priority' => $task->getPriority() ? [
|
|
'id' => $task->getPriority()->getId(),
|
|
'label' => $task->getPriority()->getLabel(),
|
|
'color' => $task->getPriority()->getColor(),
|
|
] : null,
|
|
'effort' => $task->getEffort() ? [
|
|
'id' => $task->getEffort()->getId(),
|
|
'label' => $task->getEffort()->getLabel(),
|
|
] : null,
|
|
'assignee' => $task->getAssignee() ? [
|
|
'id' => $task->getAssignee()->getId(),
|
|
'username' => $task->getAssignee()->getUsername(),
|
|
] : null,
|
|
'group' => $task->getGroup() ? [
|
|
'id' => $task->getGroup()->getId(),
|
|
'title' => $task->getGroup()->getTitle(),
|
|
] : null,
|
|
'project' => [
|
|
'id' => $project->getId(),
|
|
'code' => $project->getCode(),
|
|
'name' => $project->getName(),
|
|
],
|
|
'tags' => $task->getTags()->map(fn($t) => [
|
|
'id' => $t->getId(),
|
|
'label' => $t->getLabel(),
|
|
])->toArray(),
|
|
'archived' => $task->isArchived(),
|
|
]);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create UpdateTaskTool**
|
|
|
|
Create `src/Mcp/Tool/Task/UpdateTaskTool.php`:
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Mcp\Tool\Task;
|
|
|
|
use App\Repository\TaskEffortRepository;
|
|
use App\Repository\TaskGroupRepository;
|
|
use App\Repository\TaskPriorityRepository;
|
|
use App\Repository\TaskRepository;
|
|
use App\Repository\TaskStatusRepository;
|
|
use App\Repository\TaskTagRepository;
|
|
use App\Repository\UserRepository;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use Mcp\Capability\Attribute\McpTool;
|
|
|
|
class UpdateTaskTool
|
|
{
|
|
public function __construct(
|
|
private readonly EntityManagerInterface $entityManager,
|
|
private readonly TaskRepository $taskRepository,
|
|
private readonly TaskStatusRepository $taskStatusRepository,
|
|
private readonly TaskPriorityRepository $taskPriorityRepository,
|
|
private readonly TaskEffortRepository $taskEffortRepository,
|
|
private readonly TaskGroupRepository $taskGroupRepository,
|
|
private readonly TaskTagRepository $taskTagRepository,
|
|
private readonly UserRepository $userRepository,
|
|
) {
|
|
}
|
|
|
|
#[McpTool(name: 'update-task', description: 'Update an existing task. Only provided fields are changed. Use list-statuses, list-priorities, etc. to discover valid IDs.')]
|
|
public function __invoke(
|
|
int $id,
|
|
?string $title = null,
|
|
?string $description = null,
|
|
?int $statusId = null,
|
|
?int $priorityId = null,
|
|
?int $effortId = null,
|
|
?int $assigneeId = null,
|
|
?int $groupId = null,
|
|
?array $tagIds = null,
|
|
?bool $archived = null,
|
|
): string {
|
|
$task = $this->taskRepository->find($id);
|
|
|
|
if (null === $task) {
|
|
throw new \InvalidArgumentException(\sprintf('Task with ID %d not found.', $id));
|
|
}
|
|
|
|
if (null !== $title) {
|
|
$task->setTitle($title);
|
|
}
|
|
if (null !== $description) {
|
|
$task->setDescription($description);
|
|
}
|
|
if (null !== $statusId) {
|
|
$status = $this->taskStatusRepository->find($statusId);
|
|
if (null === $status) {
|
|
throw new \InvalidArgumentException(\sprintf('TaskStatus with ID %d not found.', $statusId));
|
|
}
|
|
$task->setStatus($status);
|
|
}
|
|
if (null !== $priorityId) {
|
|
$priority = $this->taskPriorityRepository->find($priorityId);
|
|
if (null === $priority) {
|
|
throw new \InvalidArgumentException(\sprintf('TaskPriority with ID %d not found.', $priorityId));
|
|
}
|
|
$task->setPriority($priority);
|
|
}
|
|
if (null !== $effortId) {
|
|
$effort = $this->taskEffortRepository->find($effortId);
|
|
if (null === $effort) {
|
|
throw new \InvalidArgumentException(\sprintf('TaskEffort with ID %d not found.', $effortId));
|
|
}
|
|
$task->setEffort($effort);
|
|
}
|
|
if (null !== $assigneeId) {
|
|
$assignee = $this->userRepository->find($assigneeId);
|
|
if (null === $assignee) {
|
|
throw new \InvalidArgumentException(\sprintf('User with ID %d not found.', $assigneeId));
|
|
}
|
|
$task->setAssignee($assignee);
|
|
}
|
|
if (null !== $groupId) {
|
|
$group = $this->taskGroupRepository->find($groupId);
|
|
if (null === $group) {
|
|
throw new \InvalidArgumentException(\sprintf('TaskGroup with ID %d not found.', $groupId));
|
|
}
|
|
$task->setGroup($group);
|
|
}
|
|
if (null !== $tagIds) {
|
|
// Clear existing tags and set new ones
|
|
foreach ($task->getTags()->toArray() as $existingTag) {
|
|
$task->removeTag($existingTag);
|
|
}
|
|
foreach ($tagIds as $tagId) {
|
|
$tag = $this->taskTagRepository->find($tagId);
|
|
if (null === $tag) {
|
|
throw new \InvalidArgumentException(\sprintf('TaskTag with ID %d not found.', $tagId));
|
|
}
|
|
$task->addTag($tag);
|
|
}
|
|
}
|
|
if (null !== $archived) {
|
|
$task->setArchived($archived);
|
|
}
|
|
|
|
$this->entityManager->flush();
|
|
|
|
return json_encode([
|
|
'id' => $task->getId(),
|
|
'number' => $task->getNumber(),
|
|
'title' => $task->getTitle(),
|
|
'description' => $task->getDescription(),
|
|
'status' => $task->getStatus() ? [
|
|
'id' => $task->getStatus()->getId(),
|
|
'label' => $task->getStatus()->getLabel(),
|
|
'color' => $task->getStatus()->getColor(),
|
|
] : null,
|
|
'priority' => $task->getPriority() ? [
|
|
'id' => $task->getPriority()->getId(),
|
|
'label' => $task->getPriority()->getLabel(),
|
|
'color' => $task->getPriority()->getColor(),
|
|
] : null,
|
|
'effort' => $task->getEffort() ? [
|
|
'id' => $task->getEffort()->getId(),
|
|
'label' => $task->getEffort()->getLabel(),
|
|
] : null,
|
|
'assignee' => $task->getAssignee() ? [
|
|
'id' => $task->getAssignee()->getId(),
|
|
'username' => $task->getAssignee()->getUsername(),
|
|
] : null,
|
|
'group' => $task->getGroup() ? [
|
|
'id' => $task->getGroup()->getId(),
|
|
'title' => $task->getGroup()->getTitle(),
|
|
] : null,
|
|
'project' => [
|
|
'id' => $task->getProject()->getId(),
|
|
'code' => $task->getProject()->getCode(),
|
|
'name' => $task->getProject()->getName(),
|
|
],
|
|
'tags' => $task->getTags()->map(fn($t) => [
|
|
'id' => $t->getId(),
|
|
'label' => $t->getLabel(),
|
|
])->toArray(),
|
|
'archived' => $task->isArchived(),
|
|
]);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Create DeleteTaskTool**
|
|
|
|
Create `src/Mcp/Tool/Task/DeleteTaskTool.php`:
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Mcp\Tool\Task;
|
|
|
|
use App\Repository\TaskRepository;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use Mcp\Capability\Attribute\McpTool;
|
|
|
|
class DeleteTaskTool
|
|
{
|
|
public function __construct(
|
|
private readonly TaskRepository $taskRepository,
|
|
private readonly EntityManagerInterface $entityManager,
|
|
) {
|
|
}
|
|
|
|
#[McpTool(name: 'delete-task', description: 'Delete a task permanently. This also deletes all associated documents.')]
|
|
public function __invoke(int $id): string
|
|
{
|
|
$task = $this->taskRepository->find($id);
|
|
|
|
if (null === $task) {
|
|
throw new \InvalidArgumentException(\sprintf('Task with ID %d not found.', $id));
|
|
}
|
|
|
|
$taskCode = $task->getProject()->getCode() . '-' . $task->getNumber();
|
|
$this->entityManager->remove($task);
|
|
$this->entityManager->flush();
|
|
|
|
return json_encode([
|
|
'success' => true,
|
|
'message' => \sprintf('Task %s deleted.', $taskCode),
|
|
]);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/Mcp/Tool/Task/
|
|
git commit -m "feat : add create-task, update-task, delete-task MCP tools"
|
|
```
|
|
|
|
---
|
|
|
|
## Chunk 4: TaskMeta & TimeEntry Tools
|
|
|
|
### Task 10: TaskMeta tools (statuses, priorities, efforts, tags, groups)
|
|
|
|
**Files:**
|
|
- Create: `src/Mcp/Tool/TaskMeta/ListStatusesTool.php`
|
|
- Create: `src/Mcp/Tool/TaskMeta/ListPrioritiesTool.php`
|
|
- Create: `src/Mcp/Tool/TaskMeta/ListEffortsTool.php`
|
|
- Create: `src/Mcp/Tool/TaskMeta/ListTagsTool.php`
|
|
- Create: `src/Mcp/Tool/TaskMeta/ListGroupsTool.php`
|
|
- Create: `src/Mcp/Tool/TaskMeta/CreateGroupTool.php`
|
|
- Create: `src/Mcp/Tool/TaskMeta/UpdateGroupTool.php`
|
|
|
|
- [ ] **Step 1: Create ListStatusesTool**
|
|
|
|
Create `src/Mcp/Tool/TaskMeta/ListStatusesTool.php`:
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Mcp\Tool\TaskMeta;
|
|
|
|
use App\Repository\TaskStatusRepository;
|
|
use Mcp\Capability\Attribute\McpTool;
|
|
|
|
class ListStatusesTool
|
|
{
|
|
public function __construct(
|
|
private readonly TaskStatusRepository $taskStatusRepository,
|
|
) {
|
|
}
|
|
|
|
#[McpTool(name: 'list-statuses', description: 'List all task statuses ordered by position. Statuses are global (shared across all projects). Use the returned IDs when creating or updating tasks.')]
|
|
public function __invoke(): string
|
|
{
|
|
$statuses = $this->taskStatusRepository->findBy([], ['position' => 'ASC']);
|
|
|
|
return json_encode(array_map(fn($s) => [
|
|
'id' => $s->getId(),
|
|
'label' => $s->getLabel(),
|
|
'color' => $s->getColor(),
|
|
'position' => $s->getPosition(),
|
|
'isFinal' => $s->getIsFinal(),
|
|
], $statuses));
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create ListPrioritiesTool**
|
|
|
|
Create `src/Mcp/Tool/TaskMeta/ListPrioritiesTool.php`:
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Mcp\Tool\TaskMeta;
|
|
|
|
use App\Repository\TaskPriorityRepository;
|
|
use Mcp\Capability\Attribute\McpTool;
|
|
|
|
class ListPrioritiesTool
|
|
{
|
|
public function __construct(
|
|
private readonly TaskPriorityRepository $taskPriorityRepository,
|
|
) {
|
|
}
|
|
|
|
#[McpTool(name: 'list-priorities', description: 'List all task priorities. Priorities are global (shared across all projects).')]
|
|
public function __invoke(): string
|
|
{
|
|
$priorities = $this->taskPriorityRepository->findBy([], ['label' => 'ASC']);
|
|
|
|
return json_encode(array_map(fn($p) => [
|
|
'id' => $p->getId(),
|
|
'label' => $p->getLabel(),
|
|
'color' => $p->getColor(),
|
|
], $priorities));
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Create ListEffortsTool**
|
|
|
|
Create `src/Mcp/Tool/TaskMeta/ListEffortsTool.php`:
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Mcp\Tool\TaskMeta;
|
|
|
|
use App\Repository\TaskEffortRepository;
|
|
use Mcp\Capability\Attribute\McpTool;
|
|
|
|
class ListEffortsTool
|
|
{
|
|
public function __construct(
|
|
private readonly TaskEffortRepository $taskEffortRepository,
|
|
) {
|
|
}
|
|
|
|
#[McpTool(name: 'list-efforts', description: 'List all task effort levels. Efforts are global (shared across all projects).')]
|
|
public function __invoke(): string
|
|
{
|
|
$efforts = $this->taskEffortRepository->findBy([], ['label' => 'ASC']);
|
|
|
|
return json_encode(array_map(fn($e) => [
|
|
'id' => $e->getId(),
|
|
'label' => $e->getLabel(),
|
|
], $efforts));
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Create ListTagsTool**
|
|
|
|
Create `src/Mcp/Tool/TaskMeta/ListTagsTool.php`:
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Mcp\Tool\TaskMeta;
|
|
|
|
use App\Repository\TaskTagRepository;
|
|
use Mcp\Capability\Attribute\McpTool;
|
|
|
|
class ListTagsTool
|
|
{
|
|
public function __construct(
|
|
private readonly TaskTagRepository $taskTagRepository,
|
|
) {
|
|
}
|
|
|
|
#[McpTool(name: 'list-tags', description: 'List all task tags. Tags are global (shared across all projects).')]
|
|
public function __invoke(): string
|
|
{
|
|
$tags = $this->taskTagRepository->findBy([], ['label' => 'ASC']);
|
|
|
|
return json_encode(array_map(fn($t) => [
|
|
'id' => $t->getId(),
|
|
'label' => $t->getLabel(),
|
|
'color' => $t->getColor(),
|
|
], $tags));
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Create ListGroupsTool**
|
|
|
|
Create `src/Mcp/Tool/TaskMeta/ListGroupsTool.php`:
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Mcp\Tool\TaskMeta;
|
|
|
|
use App\Repository\TaskGroupRepository;
|
|
use Mcp\Capability\Attribute\McpTool;
|
|
|
|
class ListGroupsTool
|
|
{
|
|
public function __construct(
|
|
private readonly TaskGroupRepository $taskGroupRepository,
|
|
) {
|
|
}
|
|
|
|
#[McpTool(name: 'list-groups', description: 'List task groups, optionally filtered by project. Groups are per-project (each group belongs to one project).')]
|
|
public function __invoke(?int $projectId = null, bool $archived = false): string
|
|
{
|
|
$criteria = ['archived' => $archived];
|
|
if (null !== $projectId) {
|
|
$criteria['project'] = $projectId;
|
|
}
|
|
|
|
$groups = $this->taskGroupRepository->findBy($criteria, ['title' => 'ASC']);
|
|
|
|
return json_encode(array_map(fn($g) => [
|
|
'id' => $g->getId(),
|
|
'title' => $g->getTitle(),
|
|
'description' => $g->getDescription(),
|
|
'color' => $g->getColor(),
|
|
'project' => [
|
|
'id' => $g->getProject()->getId(),
|
|
'code' => $g->getProject()->getCode(),
|
|
'name' => $g->getProject()->getName(),
|
|
],
|
|
'archived' => $g->isArchived(),
|
|
], $groups));
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: Create CreateGroupTool**
|
|
|
|
Create `src/Mcp/Tool/TaskMeta/CreateGroupTool.php`:
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Mcp\Tool\TaskMeta;
|
|
|
|
use App\Entity\TaskGroup;
|
|
use App\Repository\ProjectRepository;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use Mcp\Capability\Attribute\McpTool;
|
|
|
|
class CreateGroupTool
|
|
{
|
|
public function __construct(
|
|
private readonly EntityManagerInterface $entityManager,
|
|
private readonly ProjectRepository $projectRepository,
|
|
) {
|
|
}
|
|
|
|
#[McpTool(name: 'create-group', description: 'Create a new task group for a project')]
|
|
public function __invoke(
|
|
int $projectId,
|
|
string $title,
|
|
?string $description = null,
|
|
?string $color = null,
|
|
): string {
|
|
$project = $this->projectRepository->find($projectId);
|
|
if (null === $project) {
|
|
throw new \InvalidArgumentException(\sprintf('Project with ID %d not found.', $projectId));
|
|
}
|
|
|
|
$group = new TaskGroup();
|
|
$group->setProject($project);
|
|
$group->setTitle($title);
|
|
|
|
if (null !== $description) {
|
|
$group->setDescription($description);
|
|
}
|
|
if (null !== $color) {
|
|
$group->setColor($color);
|
|
}
|
|
|
|
$this->entityManager->persist($group);
|
|
$this->entityManager->flush();
|
|
|
|
return json_encode([
|
|
'id' => $group->getId(),
|
|
'title' => $group->getTitle(),
|
|
'description' => $group->getDescription(),
|
|
'color' => $group->getColor(),
|
|
'project' => [
|
|
'id' => $project->getId(),
|
|
'code' => $project->getCode(),
|
|
'name' => $project->getName(),
|
|
],
|
|
'archived' => $group->isArchived(),
|
|
]);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 7: Create UpdateGroupTool**
|
|
|
|
Create `src/Mcp/Tool/TaskMeta/UpdateGroupTool.php`:
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Mcp\Tool\TaskMeta;
|
|
|
|
use App\Repository\TaskGroupRepository;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use Mcp\Capability\Attribute\McpTool;
|
|
|
|
class UpdateGroupTool
|
|
{
|
|
public function __construct(
|
|
private readonly TaskGroupRepository $taskGroupRepository,
|
|
private readonly EntityManagerInterface $entityManager,
|
|
) {
|
|
}
|
|
|
|
#[McpTool(name: 'update-group', description: 'Update an existing task group. Only provided fields are changed.')]
|
|
public function __invoke(
|
|
int $id,
|
|
?string $title = null,
|
|
?string $description = null,
|
|
?string $color = null,
|
|
?bool $archived = null,
|
|
): string {
|
|
$group = $this->taskGroupRepository->find($id);
|
|
|
|
if (null === $group) {
|
|
throw new \InvalidArgumentException(\sprintf('TaskGroup with ID %d not found.', $id));
|
|
}
|
|
|
|
if (null !== $title) {
|
|
$group->setTitle($title);
|
|
}
|
|
if (null !== $description) {
|
|
$group->setDescription($description);
|
|
}
|
|
if (null !== $color) {
|
|
$group->setColor($color);
|
|
}
|
|
if (null !== $archived) {
|
|
$group->setArchived($archived);
|
|
}
|
|
|
|
$this->entityManager->flush();
|
|
|
|
return json_encode([
|
|
'id' => $group->getId(),
|
|
'title' => $group->getTitle(),
|
|
'description' => $group->getDescription(),
|
|
'color' => $group->getColor(),
|
|
'project' => [
|
|
'id' => $group->getProject()->getId(),
|
|
'code' => $group->getProject()->getCode(),
|
|
'name' => $group->getProject()->getName(),
|
|
],
|
|
'archived' => $group->isArchived(),
|
|
]);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 8: Commit**
|
|
|
|
```bash
|
|
git add src/Mcp/Tool/TaskMeta/
|
|
git commit -m "feat : add task metadata MCP tools (statuses, priorities, efforts, tags, groups CRUD)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 11: TimeEntry tools
|
|
|
|
**Files:**
|
|
- Create: `src/Mcp/Tool/TimeEntry/ListTimeEntriesTool.php`
|
|
- Create: `src/Mcp/Tool/TimeEntry/CreateTimeEntryTool.php`
|
|
- Create: `src/Mcp/Tool/TimeEntry/UpdateTimeEntryTool.php`
|
|
- Create: `src/Mcp/Tool/TimeEntry/DeleteTimeEntryTool.php`
|
|
|
|
- [ ] **Step 1: Create ListTimeEntriesTool**
|
|
|
|
Create `src/Mcp/Tool/TimeEntry/ListTimeEntriesTool.php`:
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Mcp\Tool\TimeEntry;
|
|
|
|
use App\Repository\TimeEntryRepository;
|
|
use Mcp\Capability\Attribute\McpTool;
|
|
|
|
class ListTimeEntriesTool
|
|
{
|
|
public function __construct(
|
|
private readonly TimeEntryRepository $timeEntryRepository,
|
|
) {
|
|
}
|
|
|
|
#[McpTool(name: 'list-time-entries', description: 'List time entries with optional filters. Duration is computed in minutes and null for active timers.')]
|
|
public function __invoke(
|
|
?int $userId = null,
|
|
?int $projectId = null,
|
|
?int $taskId = null,
|
|
?string $startDate = null,
|
|
?string $endDate = null,
|
|
int $limit = 100,
|
|
): string {
|
|
$limit = min($limit, 200);
|
|
|
|
$qb = $this->timeEntryRepository->createQueryBuilder('te')
|
|
->leftJoin('te.user', 'u')->addSelect('u')
|
|
->leftJoin('te.project', 'p')->addSelect('p')
|
|
->leftJoin('te.task', 't')->addSelect('t')
|
|
->leftJoin('te.tags', 'tg')->addSelect('tg')
|
|
->orderBy('te.startedAt', 'DESC')
|
|
->setMaxResults($limit);
|
|
|
|
if (null !== $userId) {
|
|
$qb->andWhere('u.id = :userId')->setParameter('userId', $userId);
|
|
}
|
|
if (null !== $projectId) {
|
|
$qb->andWhere('p.id = :projectId')->setParameter('projectId', $projectId);
|
|
}
|
|
if (null !== $taskId) {
|
|
$qb->andWhere('t.id = :taskId')->setParameter('taskId', $taskId);
|
|
}
|
|
if (null !== $startDate) {
|
|
$qb->andWhere('te.startedAt >= :startDate')
|
|
->setParameter('startDate', new \DateTimeImmutable($startDate . ' 00:00:00'));
|
|
}
|
|
if (null !== $endDate) {
|
|
$qb->andWhere('te.startedAt <= :endDate')
|
|
->setParameter('endDate', new \DateTimeImmutable($endDate . ' 23:59:59'));
|
|
}
|
|
|
|
$entries = $qb->getQuery()->getResult();
|
|
|
|
return json_encode(array_map(fn($entry) => [
|
|
'id' => $entry->getId(),
|
|
'title' => $entry->getTitle(),
|
|
'description' => $entry->getDescription(),
|
|
'startedAt' => $entry->getStartedAt()?->format('c'),
|
|
'stoppedAt' => $entry->getStoppedAt()?->format('c'),
|
|
'duration' => $entry->getStoppedAt() && $entry->getStartedAt()
|
|
? (int) round(($entry->getStoppedAt()->getTimestamp() - $entry->getStartedAt()->getTimestamp()) / 60)
|
|
: null,
|
|
'user' => [
|
|
'id' => $entry->getUser()->getId(),
|
|
'username' => $entry->getUser()->getUsername(),
|
|
],
|
|
'project' => $entry->getProject() ? [
|
|
'id' => $entry->getProject()->getId(),
|
|
'code' => $entry->getProject()->getCode(),
|
|
'name' => $entry->getProject()->getName(),
|
|
] : null,
|
|
'task' => $entry->getTask() ? [
|
|
'id' => $entry->getTask()->getId(),
|
|
'number' => $entry->getTask()->getNumber(),
|
|
'title' => $entry->getTask()->getTitle(),
|
|
] : null,
|
|
'tags' => $entry->getTags()->map(fn($t) => [
|
|
'id' => $t->getId(),
|
|
'label' => $t->getLabel(),
|
|
])->toArray(),
|
|
], $entries));
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create CreateTimeEntryTool**
|
|
|
|
Create `src/Mcp/Tool/TimeEntry/CreateTimeEntryTool.php`:
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Mcp\Tool\TimeEntry;
|
|
|
|
use App\Entity\TimeEntry;
|
|
use App\Repository\ProjectRepository;
|
|
use App\Repository\TaskRepository;
|
|
use App\Repository\TaskTagRepository;
|
|
use App\Repository\TimeEntryRepository;
|
|
use App\Repository\UserRepository;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use Mcp\Capability\Attribute\McpTool;
|
|
|
|
class CreateTimeEntryTool
|
|
{
|
|
public function __construct(
|
|
private readonly EntityManagerInterface $entityManager,
|
|
private readonly UserRepository $userRepository,
|
|
private readonly ProjectRepository $projectRepository,
|
|
private readonly TaskRepository $taskRepository,
|
|
private readonly TaskTagRepository $taskTagRepository,
|
|
private readonly TimeEntryRepository $timeEntryRepository,
|
|
) {
|
|
}
|
|
|
|
#[McpTool(name: 'create-time-entry', description: 'Create a time entry. If stoppedAt is null, creates an active timer. Only one active timer per user is allowed.')]
|
|
public function __invoke(
|
|
int $userId,
|
|
string $startedAt,
|
|
?string $title = null,
|
|
?string $stoppedAt = null,
|
|
?int $projectId = null,
|
|
?int $taskId = null,
|
|
?array $tagIds = null,
|
|
?string $description = null,
|
|
): string {
|
|
$user = $this->userRepository->find($userId);
|
|
if (null === $user) {
|
|
throw new \InvalidArgumentException(\sprintf('User with ID %d not found.', $userId));
|
|
}
|
|
|
|
// Check for existing active timer if creating a new active one
|
|
if (null === $stoppedAt) {
|
|
$activeEntry = $this->timeEntryRepository->findActiveByUser($user);
|
|
if (null !== $activeEntry) {
|
|
throw new \InvalidArgumentException(\sprintf('User "%s" already has an active timer (ID %d). Stop it before starting a new one.', $user->getUsername(), $activeEntry->getId()));
|
|
}
|
|
}
|
|
|
|
$entry = new TimeEntry();
|
|
$entry->setUser($user);
|
|
$entry->setStartedAt(new \DateTimeImmutable($startedAt));
|
|
|
|
if (null !== $title) {
|
|
$entry->setTitle($title);
|
|
}
|
|
if (null !== $stoppedAt) {
|
|
$entry->setStoppedAt(new \DateTimeImmutable($stoppedAt));
|
|
}
|
|
if (null !== $description) {
|
|
$entry->setDescription($description);
|
|
}
|
|
if (null !== $projectId) {
|
|
$project = $this->projectRepository->find($projectId);
|
|
if (null === $project) {
|
|
throw new \InvalidArgumentException(\sprintf('Project with ID %d not found.', $projectId));
|
|
}
|
|
$entry->setProject($project);
|
|
}
|
|
if (null !== $taskId) {
|
|
$task = $this->taskRepository->find($taskId);
|
|
if (null === $task) {
|
|
throw new \InvalidArgumentException(\sprintf('Task with ID %d not found.', $taskId));
|
|
}
|
|
$entry->setTask($task);
|
|
}
|
|
if (null !== $tagIds) {
|
|
foreach ($tagIds as $tagId) {
|
|
$tag = $this->taskTagRepository->find($tagId);
|
|
if (null === $tag) {
|
|
throw new \InvalidArgumentException(\sprintf('TaskTag with ID %d not found.', $tagId));
|
|
}
|
|
$entry->addTag($tag);
|
|
}
|
|
}
|
|
|
|
$this->entityManager->persist($entry);
|
|
$this->entityManager->flush();
|
|
|
|
return json_encode([
|
|
'id' => $entry->getId(),
|
|
'title' => $entry->getTitle(),
|
|
'description' => $entry->getDescription(),
|
|
'startedAt' => $entry->getStartedAt()?->format('c'),
|
|
'stoppedAt' => $entry->getStoppedAt()?->format('c'),
|
|
'duration' => $entry->getStoppedAt() && $entry->getStartedAt()
|
|
? (int) round(($entry->getStoppedAt()->getTimestamp() - $entry->getStartedAt()->getTimestamp()) / 60)
|
|
: null,
|
|
'user' => ['id' => $user->getId(), 'username' => $user->getUsername()],
|
|
'project' => $entry->getProject() ? [
|
|
'id' => $entry->getProject()->getId(),
|
|
'code' => $entry->getProject()->getCode(),
|
|
'name' => $entry->getProject()->getName(),
|
|
] : null,
|
|
'task' => $entry->getTask() ? [
|
|
'id' => $entry->getTask()->getId(),
|
|
'number' => $entry->getTask()->getNumber(),
|
|
'title' => $entry->getTask()->getTitle(),
|
|
] : null,
|
|
'tags' => $entry->getTags()->map(fn($t) => [
|
|
'id' => $t->getId(),
|
|
'label' => $t->getLabel(),
|
|
])->toArray(),
|
|
]);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Create UpdateTimeEntryTool**
|
|
|
|
Create `src/Mcp/Tool/TimeEntry/UpdateTimeEntryTool.php`:
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Mcp\Tool\TimeEntry;
|
|
|
|
use App\Repository\ProjectRepository;
|
|
use App\Repository\TaskRepository;
|
|
use App\Repository\TaskTagRepository;
|
|
use App\Repository\TimeEntryRepository;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use Mcp\Capability\Attribute\McpTool;
|
|
|
|
class UpdateTimeEntryTool
|
|
{
|
|
public function __construct(
|
|
private readonly TimeEntryRepository $timeEntryRepository,
|
|
private readonly ProjectRepository $projectRepository,
|
|
private readonly TaskRepository $taskRepository,
|
|
private readonly TaskTagRepository $taskTagRepository,
|
|
private readonly EntityManagerInterface $entityManager,
|
|
) {
|
|
}
|
|
|
|
#[McpTool(name: 'update-time-entry', description: 'Update a time entry. Use to stop an active timer by providing stoppedAt, or to correct start time. userId is not updatable.')]
|
|
public function __invoke(
|
|
int $id,
|
|
?string $title = null,
|
|
?string $startedAt = null,
|
|
?string $stoppedAt = null,
|
|
?int $projectId = null,
|
|
?int $taskId = null,
|
|
?array $tagIds = null,
|
|
?string $description = null,
|
|
): string {
|
|
$entry = $this->timeEntryRepository->find($id);
|
|
|
|
if (null === $entry) {
|
|
throw new \InvalidArgumentException(\sprintf('TimeEntry with ID %d not found.', $id));
|
|
}
|
|
|
|
if (null !== $title) {
|
|
$entry->setTitle($title);
|
|
}
|
|
if (null !== $startedAt) {
|
|
$entry->setStartedAt(new \DateTimeImmutable($startedAt));
|
|
}
|
|
if (null !== $stoppedAt) {
|
|
$entry->setStoppedAt(new \DateTimeImmutable($stoppedAt));
|
|
}
|
|
if (null !== $description) {
|
|
$entry->setDescription($description);
|
|
}
|
|
if (null !== $projectId) {
|
|
$project = $this->projectRepository->find($projectId);
|
|
if (null === $project) {
|
|
throw new \InvalidArgumentException(\sprintf('Project with ID %d not found.', $projectId));
|
|
}
|
|
$entry->setProject($project);
|
|
}
|
|
if (null !== $taskId) {
|
|
$task = $this->taskRepository->find($taskId);
|
|
if (null === $task) {
|
|
throw new \InvalidArgumentException(\sprintf('Task with ID %d not found.', $taskId));
|
|
}
|
|
$entry->setTask($task);
|
|
}
|
|
if (null !== $tagIds) {
|
|
foreach ($entry->getTags()->toArray() as $existingTag) {
|
|
$entry->removeTag($existingTag);
|
|
}
|
|
foreach ($tagIds as $tagId) {
|
|
$tag = $this->taskTagRepository->find($tagId);
|
|
if (null === $tag) {
|
|
throw new \InvalidArgumentException(\sprintf('TaskTag with ID %d not found.', $tagId));
|
|
}
|
|
$entry->addTag($tag);
|
|
}
|
|
}
|
|
|
|
$this->entityManager->flush();
|
|
|
|
return json_encode([
|
|
'id' => $entry->getId(),
|
|
'title' => $entry->getTitle(),
|
|
'startedAt' => $entry->getStartedAt()?->format('c'),
|
|
'stoppedAt' => $entry->getStoppedAt()?->format('c'),
|
|
'duration' => $entry->getStoppedAt() && $entry->getStartedAt()
|
|
? (int) round(($entry->getStoppedAt()->getTimestamp() - $entry->getStartedAt()->getTimestamp()) / 60)
|
|
: null,
|
|
'user' => ['id' => $entry->getUser()->getId(), 'username' => $entry->getUser()->getUsername()],
|
|
'project' => $entry->getProject() ? [
|
|
'id' => $entry->getProject()->getId(),
|
|
'code' => $entry->getProject()->getCode(),
|
|
'name' => $entry->getProject()->getName(),
|
|
] : null,
|
|
'task' => $entry->getTask() ? [
|
|
'id' => $entry->getTask()->getId(),
|
|
'number' => $entry->getTask()->getNumber(),
|
|
'title' => $entry->getTask()->getTitle(),
|
|
] : null,
|
|
'tags' => $entry->getTags()->map(fn($t) => [
|
|
'id' => $t->getId(),
|
|
'label' => $t->getLabel(),
|
|
])->toArray(),
|
|
]);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Create DeleteTimeEntryTool**
|
|
|
|
Create `src/Mcp/Tool/TimeEntry/DeleteTimeEntryTool.php`:
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Mcp\Tool\TimeEntry;
|
|
|
|
use App\Repository\TimeEntryRepository;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use Mcp\Capability\Attribute\McpTool;
|
|
|
|
class DeleteTimeEntryTool
|
|
{
|
|
public function __construct(
|
|
private readonly TimeEntryRepository $timeEntryRepository,
|
|
private readonly EntityManagerInterface $entityManager,
|
|
) {
|
|
}
|
|
|
|
#[McpTool(name: 'delete-time-entry', description: 'Delete a time entry permanently')]
|
|
public function __invoke(int $id): string
|
|
{
|
|
$entry = $this->timeEntryRepository->find($id);
|
|
|
|
if (null === $entry) {
|
|
throw new \InvalidArgumentException(\sprintf('TimeEntry with ID %d not found.', $id));
|
|
}
|
|
|
|
$this->entityManager->remove($entry);
|
|
$this->entityManager->flush();
|
|
|
|
return json_encode([
|
|
'success' => true,
|
|
'message' => 'Time entry deleted.',
|
|
]);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/Mcp/Tool/TimeEntry/
|
|
git commit -m "feat : add time entry MCP tools (list, create, update, delete)"
|
|
```
|
|
|
|
---
|
|
|
|
## Chunk 5: Integration Testing & Claude Code Setup
|
|
|
|
### Task 12: End-to-end verification
|
|
|
|
- [ ] **Step 1: Clear cache and verify all tools are registered**
|
|
|
|
```bash
|
|
docker exec -u www-data php-lesstime-fpm php bin/console cache:clear
|
|
```
|
|
|
|
Then list all tools via STDIO:
|
|
```bash
|
|
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | docker exec -i php-lesstime-fpm php bin/console mcp:server
|
|
```
|
|
|
|
Expected: JSON response listing all 21 tools: `list-users`, `list-clients`, `list-projects`, `get-project`, `create-project`, `update-project`, `list-tasks`, `get-task`, `create-task`, `update-task`, `delete-task`, `list-statuses`, `list-priorities`, `list-efforts`, `list-tags`, `list-groups`, `create-group`, `update-group`, `list-time-entries`, `create-time-entry`, `update-time-entry`, `delete-time-entry` (note: delete-time-entry = 22nd tool, but spec counts 21 — recheck).
|
|
|
|
- [ ] **Step 2: Test a tool call via STDIO**
|
|
|
|
```bash
|
|
echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list-projects","arguments":{}}}' | docker exec -i php-lesstime-fpm php bin/console mcp:server
|
|
```
|
|
|
|
Expected: JSON response with the list of fixture projects (SIRH, CRM, ERP, Site vitrine).
|
|
|
|
- [ ] **Step 3: Test HTTP transport with auth**
|
|
|
|
```bash
|
|
docker restart nginx-lesstime
|
|
```
|
|
|
|
Initialize MCP session:
|
|
```bash
|
|
curl -s -X POST http://localhost:8082/_mcp \
|
|
-H "Content-Type: application/json" \
|
|
-H "Authorization: Bearer dev-mcp-token-for-testing-only-do-not-use-in-production" \
|
|
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl-test","version":"1.0"}}}'
|
|
```
|
|
|
|
Expected: JSON-RPC response with server info and capabilities.
|
|
|
|
- [ ] **Step 4: Test HTTP auth rejection**
|
|
|
|
```bash
|
|
curl -s -X POST http://localhost:8082/_mcp \
|
|
-H "Content-Type: application/json" \
|
|
-H "Authorization: Bearer wrong-token" \
|
|
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl-test","version":"1.0"}}}'
|
|
```
|
|
|
|
Expected: 401 Unauthorized response.
|
|
|
|
- [ ] **Step 5: Configure Claude Code (STDIO)**
|
|
|
|
Add to `.claude/settings.json` (or project-level `.claude/settings.local.json`):
|
|
|
|
```json
|
|
{
|
|
"mcpServers": {
|
|
"lesstime": {
|
|
"command": "docker",
|
|
"args": ["exec", "-i", "php-lesstime-fpm", "php", "bin/console", "mcp:server"],
|
|
"cwd": "/home/r-dev/Lesstime"
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: Run PHP CS Fixer on all new files**
|
|
|
|
```bash
|
|
docker exec -u www-data php-lesstime-fpm vendor/bin/php-cs-fixer fix src/Mcp/ --rules=@Symfony,@PSR12 --allow-risky=yes
|
|
docker exec -u www-data php-lesstime-fpm vendor/bin/php-cs-fixer fix src/Security/ApiTokenAuthenticator.php --rules=@Symfony,@PSR12 --allow-risky=yes
|
|
docker exec -u www-data php-lesstime-fpm vendor/bin/php-cs-fixer fix src/Command/GenerateApiTokenCommand.php --rules=@Symfony,@PSR12 --allow-risky=yes
|
|
```
|
|
|
|
- [ ] **Step 7: Final commit if CS Fixer changed anything**
|
|
|
|
```bash
|
|
git add -A src/Mcp/ src/Security/ src/Command/
|
|
git diff --cached --quiet || git commit -m "style : apply PHP CS Fixer to MCP server code"
|
|
```
|