diff --git a/CLAUDE.md b/CLAUDE.md index 87575ec..54bb893 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -109,7 +109,7 @@ La librairie `@malio/layer-ui` fournit les composants de formulaire et d'action. ### 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 HTTP (réseau) : `POST /_mcp` avec header `Authorization: Bearer ` - Auth HTTP : `ApiTokenAuthenticator` vérifie le champ `apiToken` de l'entité `User` diff --git a/config/services.yaml b/config/services.yaml index 497cda6..583afc5 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -49,6 +49,10 @@ services: arguments: $uploadDir: '%task_document_upload_dir%' + App\Mcp\Tool\Task\UpdateTaskDocumentTool: + arguments: + $uploadDir: '%task_document_upload_dir%' + App\Controller\UserAvatarController: arguments: $avatarUploadDir: '%avatar_upload_dir%' diff --git a/src/Mcp/Tool/Task/DeleteTaskDocumentTool.php b/src/Mcp/Tool/Task/DeleteTaskDocumentTool.php new file mode 100644 index 0000000..99ffa12 --- /dev/null +++ b/src/Mcp/Tool/Task/DeleteTaskDocumentTool.php @@ -0,0 +1,52 @@ +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, + ]); + } +} diff --git a/src/Mcp/Tool/Task/UpdateTaskDocumentTool.php b/src/Mcp/Tool/Task/UpdateTaskDocumentTool.php new file mode 100644 index 0000000..59cc4a4 --- /dev/null +++ b/src/Mcp/Tool/Task/UpdateTaskDocumentTool.php @@ -0,0 +1,108 @@ + '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(), + ]); + } +}