Compare commits

...

2 Commits

Author SHA1 Message Date
gitea-actions 8475f9604c chore: bump version to v0.4.24
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 23s
2026-06-02 07:51:11 +00:00
Matthieu 226ab8ea84 feat(mcp) : tools update et delete des documents de tâche
Auto Tag Develop / tag (push) Successful in 7s
Ajoute deux tools MCP sur le modèle de add-task-document :
- update-task-document : remplace le contenu et/ou renomme un document (MIME ré-inféré, taille rafraîchie, garde-fous vide/5 Mo)
- delete-task-document : supprime le document en base, le fichier disque étant retiré par le PreRemove listener

Met aussi à jour le compteur de tools MCP dans le CLAUDE.md (60).
2026-06-02 09:50:03 +02:00
5 changed files with 166 additions and 2 deletions
+1 -1
View File
@@ -109,7 +109,7 @@ La librairie `@malio/layer-ui` fournit les composants de formulaire et d'action.
### MCP Server ### MCP Server
- 25 tools MCP exposant projets, tâches, métadonnées, time tracking, et récurrences - 60 tools MCP exposant projets, tâches, métadonnées, time tracking, récurrences, documents et absences
- Transport STDIO (local) : `docker exec -i php-lesstime-fpm php bin/console mcp:server` - Transport STDIO (local) : `docker exec -i php-lesstime-fpm php bin/console mcp:server`
- Transport HTTP (réseau) : `POST /_mcp` avec header `Authorization: Bearer <token>` - Transport HTTP (réseau) : `POST /_mcp` avec header `Authorization: Bearer <token>`
- Auth HTTP : `ApiTokenAuthenticator` vérifie le champ `apiToken` de l'entité `User` - Auth HTTP : `ApiTokenAuthenticator` vérifie le champ `apiToken` de l'entité `User`
+4
View File
@@ -49,6 +49,10 @@ services:
arguments: arguments:
$uploadDir: '%task_document_upload_dir%' $uploadDir: '%task_document_upload_dir%'
App\Mcp\Tool\Task\UpdateTaskDocumentTool:
arguments:
$uploadDir: '%task_document_upload_dir%'
App\Controller\UserAvatarController: App\Controller\UserAvatarController:
arguments: arguments:
$avatarUploadDir: '%avatar_upload_dir%' $avatarUploadDir: '%avatar_upload_dir%'
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.4.23' app.version: '0.4.24'
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Task;
use App\Entity\TaskDocument;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'delete-task-document', description: 'Delete a document attached to a task, permanently. The underlying file is also removed from disk.')]
class DeleteTaskDocumentTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
/**
* @param int $id ID of the task document to delete
*/
public function __invoke(int $id): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$document = $this->entityManager->find(TaskDocument::class, $id);
if (null === $document) {
throw new InvalidArgumentException(sprintf('Task document with ID %d not found.', $id));
}
$taskId = $document->getTask()?->getId();
$originalName = $document->getOriginalName();
$this->entityManager->remove($document);
$this->entityManager->flush();
return json_encode([
'success' => true,
'message' => sprintf('Document "%s" (ID %d) deleted.', $originalName, $id),
'id' => $id,
'taskId' => $taskId,
'originalName' => $originalName,
]);
}
}
@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Task;
use App\Entity\TaskDocument;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
use function strlen;
#[McpTool(name: 'update-task-document', description: 'Update a document attached to a task: replace its text content and/or rename it. Pass the new raw content (verbatim UTF-8) and/or a new fileName. The MIME type is re-inferred from the fileName extension. At least one of content or fileName must be provided.')]
class UpdateTaskDocumentTool
{
private const MAX_CONTENT_SIZE = 5 * 1024 * 1024; // 5 MB of text
private const EXTENSION_TO_MIME = [
'md' => 'text/markdown',
'markdown' => 'text/markdown',
'txt' => 'text/plain',
'csv' => 'text/csv',
'json' => 'application/json',
'xml' => 'text/xml',
];
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
private readonly string $uploadDir,
) {}
/**
* @param int $id ID of the task document to update
* @param null|string $content New raw text content of the document (e.g. Markdown). Omit to keep the current content.
* @param null|string $fileName New display name of the document, including extension. Omit to keep the current name.
*/
public function __invoke(
int $id,
?string $content = null,
?string $fileName = null,
): string {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
if (null === $content && null === $fileName) {
throw new InvalidArgumentException('At least one of content or fileName must be provided.');
}
$document = $this->entityManager->find(TaskDocument::class, $id);
if (null === $document) {
throw new InvalidArgumentException(sprintf('Task document with ID %d not found.', $id));
}
// Rename: update the display name and re-infer the MIME type from its extension.
if (null !== $fileName) {
$originalName = trim($fileName);
if ('' === $originalName) {
throw new InvalidArgumentException('fileName cannot be empty.');
}
$extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
if ('' === $extension) {
$originalName .= '.md';
$extension = 'md';
}
$document->setOriginalName($originalName);
$document->setMimeType(self::EXTENSION_TO_MIME[$extension] ?? 'text/markdown');
}
// Replace content: overwrite the stored file in place and refresh its size.
if (null !== $content) {
if ('' === $content) {
throw new InvalidArgumentException('Document content cannot be empty.');
}
$size = strlen($content);
if ($size > self::MAX_CONTENT_SIZE) {
throw new InvalidArgumentException('Content size exceeds 5 MB limit.');
}
$filePath = $this->uploadDir.'/'.$document->getFileName();
if (false === file_put_contents($filePath, $content)) {
throw new InvalidArgumentException('Failed to write document to disk.');
}
$document->setSize($size);
}
$this->entityManager->flush();
return json_encode([
'id' => $document->getId(),
'taskId' => $document->getTask()?->getId(),
'originalName' => $document->getOriginalName(),
'mimeType' => $document->getMimeType(),
'size' => $document->getSize(),
'createdAt' => $document->getCreatedAt()?->format('c'),
'uploadedBy' => $document->getUploadedBy()?->getUsername(),
]);
}
}