From 022cffea2cc338ac350efac4236238fbcb8106c6 Mon Sep 17 00:00:00 2001 From: ayoub cherif Date: Thu, 2 Jul 2026 14:52:19 +0100 Subject: [PATCH 01/15] task-88259: Create FB ai-contribution and update projects versions/dependencies --- documents-api/pom.xml | 2 +- documents-packaging/pom.xml | 2 +- documents-services/pom.xml | 2 +- documents-storage-jcr/pom.xml | 2 +- documents-webapp/pom.xml | 2 +- documents-webdav-webapp/pom.xml | 2 +- pom.xml | 4 ++-- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/documents-api/pom.xml b/documents-api/pom.xml index fbfa57f06..8b646ac50 100644 --- a/documents-api/pom.xml +++ b/documents-api/pom.xml @@ -3,7 +3,7 @@ org.exoplatform.documents documents-parent - 7.3.x-SNAPSHOT + 7.3.x-ai-contribution-SNAPSHOT documents-api eXo Documents - API diff --git a/documents-packaging/pom.xml b/documents-packaging/pom.xml index fee459474..4d3880f12 100644 --- a/documents-packaging/pom.xml +++ b/documents-packaging/pom.xml @@ -4,7 +4,7 @@ org.exoplatform.documents documents-parent - 7.3.x-SNAPSHOT + 7.3.x-ai-contribution-SNAPSHOT documents-packaging pom diff --git a/documents-services/pom.xml b/documents-services/pom.xml index 1c19fcdd0..4238525e0 100644 --- a/documents-services/pom.xml +++ b/documents-services/pom.xml @@ -3,7 +3,7 @@ org.exoplatform.documents documents-parent - 7.3.x-SNAPSHOT + 7.3.x-ai-contribution-SNAPSHOT documents-services eXo Documents - Services diff --git a/documents-storage-jcr/pom.xml b/documents-storage-jcr/pom.xml index 5e9ad7f98..a05bde77b 100644 --- a/documents-storage-jcr/pom.xml +++ b/documents-storage-jcr/pom.xml @@ -3,7 +3,7 @@ org.exoplatform.documents documents-parent - 7.3.x-SNAPSHOT + 7.3.x-ai-contribution-SNAPSHOT documents-storage-jcr eXo Documents - Storage JCR Implementation diff --git a/documents-webapp/pom.xml b/documents-webapp/pom.xml index adefbda50..be44e4d1c 100644 --- a/documents-webapp/pom.xml +++ b/documents-webapp/pom.xml @@ -3,7 +3,7 @@ org.exoplatform.documents documents-parent - 7.3.x-SNAPSHOT + 7.3.x-ai-contribution-SNAPSHOT documents-webapp war diff --git a/documents-webdav-webapp/pom.xml b/documents-webdav-webapp/pom.xml index 326384f21..566622e6d 100644 --- a/documents-webdav-webapp/pom.xml +++ b/documents-webdav-webapp/pom.xml @@ -3,7 +3,7 @@ org.exoplatform.documents documents-parent - 7.3.x-SNAPSHOT + 7.3.x-ai-contribution-SNAPSHOT documents-webdav-webapp war diff --git a/pom.xml b/pom.xml index 767e74725..b215f24b4 100644 --- a/pom.xml +++ b/pom.xml @@ -9,7 +9,7 @@ org.exoplatform.documents documents-parent - 7.3.x-SNAPSHOT + 7.3.x-ai-contribution-SNAPSHOT pom eXo Documents - Parent POM @@ -28,7 +28,7 @@ - 7.3.x-SNAPSHOT + 7.3.x-ai-contribution-SNAPSHOT 2.11.5 exoplatform From 74354b6cea80c53f08c79151ac5c55cd68d1c0e4 Mon Sep 17 00:00:00 2001 From: Benjamin Mestrallet Date: Mon, 6 Jul 2026 18:23:31 +0100 Subject: [PATCH 02/15] feat: Relocate Document MCP tools into the Documents add-on Move the generic document MCP tools out of the enterprise distribution into the open Documents add-on so they ship in all editions. - Add DocumentMcpTool (@Service @Profile("mcp-server")) exposing the 9 document tools (get_root_folder_by_space, attach_document_to_content, update_document_description, get_documents_by_folder_id, get_root_folder_for_user, get_document_by_id, get_document_content_by_id, get_document_transcription_by_id, search_documents) as the current user. - get_document_transcription_by_id is now a plain read of the stored transcription (DocumentFileService.getAudioTranscription) with no ai-agent-service coupling; on-demand AI transcription stays enterprise. - Add documents-services ai-tool-definitions.json with the 9 tool entries. - documents-services: add mcp-server-tools (provided), ecms-core-services (AttachmentService), notes-service (NoteService) and the -parameters javac flag required for MCP argument binding by name. The AI-experience layer (UX bindings, curated prompts, auto-transcription listener) intentionally remains in the enterprise edition. Co-Authored-By: Claude Opus 4.8 (1M context) --- documents-services/pom.xml | 29 ++ .../documents/mcp/DocumentMcpTool.java | 333 ++++++++++++++++++ .../main/resources/ai-tool-definitions.json | 222 ++++++++++++ pom.xml | 2 + 4 files changed, 586 insertions(+) create mode 100644 documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java create mode 100644 documents-services/src/main/resources/ai-tool-definitions.json diff --git a/documents-services/pom.xml b/documents-services/pom.xml index 4238525e0..18db90b6a 100644 --- a/documents-services/pom.xml +++ b/documents-services/pom.xml @@ -41,6 +41,25 @@ jar provided + + + io.meeds.mcp-server + mcp-server-tools + provided + + + + org.exoplatform.ecms + ecms-core-services + ${addon.exo.ecms.version} + provided + + + + io.meeds.notes + notes-service + provided + io.meeds.social @@ -62,6 +81,16 @@ documents-services + + org.apache.maven.plugins + maven-compiler-plugin + + + + -parameters + + + io.openapitools.swagger swagger-maven-plugin diff --git a/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java b/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java new file mode 100644 index 000000000..3544abecb --- /dev/null +++ b/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java @@ -0,0 +1,333 @@ +/** + * Copyright (C) 2026 eXo Platform SAS. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.exoplatform.documents.mcp; + +import static io.meeds.mcp.server.tool.util.McpToolPluginUtils.getInteger; +import static io.meeds.mcp.server.util.McpToolUtils.formatDate; + +import java.util.List; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import org.exoplatform.commons.exception.ObjectNotFoundException; +import org.exoplatform.container.ExoContainerContext; +import org.exoplatform.documents.constant.FileListingType; +import org.exoplatform.documents.model.AbstractNode; +import org.exoplatform.documents.model.DocumentFileModel; +import org.exoplatform.documents.model.DocumentFolderFilter; +import org.exoplatform.documents.model.DocumentFolderModel; +import org.exoplatform.documents.model.DocumentModel; +import org.exoplatform.documents.model.DocumentTimelineFilter; +import org.exoplatform.documents.model.FileNode; +import org.exoplatform.documents.model.FolderNode; +import org.exoplatform.documents.service.DocumentFileService; +import org.exoplatform.portal.config.UserACL; +import org.exoplatform.portal.config.UserPortalConfigService; +import org.exoplatform.services.attachments.service.AttachmentService; +import org.exoplatform.social.core.identity.model.Identity; +import org.exoplatform.social.core.manager.IdentityManager; +import org.exoplatform.social.core.profileproperty.ProfilePropertyService; +import org.exoplatform.social.core.space.model.Space; +import org.exoplatform.social.core.space.spi.SpaceService; +import org.exoplatform.wiki.WikiException; +import org.exoplatform.wiki.model.Page; +import org.exoplatform.wiki.service.NoteService; + +import io.meeds.mcp.server.plugin.McpToolPlugin; +import io.meeds.mcp.server.tool.model.UserModel; +import io.meeds.mcp.server.tool.util.UserToolUtils; +import io.meeds.social.translation.service.TranslationService; + +import lombok.SneakyThrows; + +/** + * MCP tools exposing the Documents (DMS) add-on to the AI agent (EVA). Every + * method acts as the current user, so document ACLs are enforced by + * {@link DocumentFileService}. These tools only READ or update document + * metadata / content; they carry no coupling to the AI service layer (the + * AI-powered document experiences remain in the enterprise edition). + */ +@Service +@Profile("mcp-server") +public class DocumentMcpTool implements McpToolPlugin { + + public static final String FOLDER_URL_FORMAT = "/portal/dw/documents?folderId=%s"; + + public static final String FILE_URL_FORMAT = "/portal/dw/documents?documentPreviewId=%s"; + + private final DocumentFileService documentFileService; + + private final AttachmentService attachmentService; + + private final IdentityManager identityManager; + + private final SpaceService spaceService; + + private final TranslationService translationService; + + private final ProfilePropertyService profilePropertyService; + + private final UserACL userAcl; + + private final UserPortalConfigService portalConfigService; + + public DocumentMcpTool(DocumentFileService documentFileService, + AttachmentService attachmentService, + IdentityManager identityManager, + SpaceService spaceService, + TranslationService translationService, + ProfilePropertyService profilePropertyService, + UserACL userAcl, + UserPortalConfigService portalConfigService) { + this.documentFileService = documentFileService; + this.attachmentService = attachmentService; + this.identityManager = identityManager; + this.spaceService = spaceService; + this.translationService = translationService; + this.profilePropertyService = profilePropertyService; + this.userAcl = userAcl; + this.portalConfigService = portalConfigService; + } + + public DocumentFolderModel getRootFolderBySpace(long spaceId) throws ObjectNotFoundException, IllegalAccessException { + FolderNode spaceRootFolder = documentFileService.getSpaceRootFolder(spaceId, getCurrentUserAclIdentity()); + return toDocumentFolderModel(spaceRootFolder); + } + + public void attachDocumentToContent(String documentId, + String contentType, + long contentId) throws IllegalAccessException { + // FIXME Why using this content type name ??!! + contentId = fixNoteContentId(contentType, contentId); + // FIXME Why using this content type name ??!! + contentType = fixNoteContentType(contentType); + attachmentService.linkAttachmentToEntity(getCurrentUserIdentityId(), contentId, contentType, documentId); + } + + public void updateDocumentDescription(String documentId, String htmlDescription) throws IllegalAccessException, Exception { // NOSONAR + long ownerId = documentFileService.getRootFolderOwnerId(documentId); + documentFileService.updateDocumentDescription(ownerId, documentId, htmlDescription, getCurrentUserIdentityId()); + } + + public List getDocumentsByFolderId(String folderId, + Boolean filesOnly, + Boolean foldersOnly, + Integer offset, + Integer limit) throws ObjectNotFoundException, + IllegalAccessException { + List documentItems = documentFileService.getDocumentItems(FileListingType.FOLDER, + new DocumentFolderFilter(folderId, + null, + null, + null), + getInteger(offset, DEFAULT_OFFSET), + getInteger(limit, DEFAULT_LIMIT), + getCurrentUserIdentityId(), + true); + return documentItems.stream() + .filter(d -> (filesOnly == null || !filesOnly || d instanceof FileNode) + || (foldersOnly == null || !foldersOnly || d instanceof FolderNode)) + .map(this::toDocumentModel) + .toList(); + } + + public DocumentFolderModel getRootFolderForUser() { + FolderNode personalRootFolder = documentFileService.getPersonalRootFolder(getCurrentUserAclIdentity()); + return toDocumentFolderModel(personalRootFolder); + } + + public DocumentModel getDocumentById(String documentId) throws IllegalAccessException, ObjectNotFoundException { + checkDocumentIdParameter(documentId); + AbstractNode document = documentFileService.getDocumentById(documentId, getCurrentUserName()); + return toDocumentModel(document); + } + + public String getDocumentContentById(String documentId) throws IllegalAccessException, ObjectNotFoundException { + checkCanAccessDocument(documentId); + return documentFileService.getFileContentAsText(documentId); + } + + public String getDocumentTranscriptionById(String documentId) throws IllegalAccessException, ObjectNotFoundException { + AbstractNode document = checkCanAccessDocument(documentId); + String audioTranscription = documentFileService.getAudioTranscription(documentId, getCurrentUserIdentityId()); + if (StringUtils.isNotBlank(audioTranscription)) { + return audioTranscription; + } else if (document instanceof FileNode fileNode + && StringUtils.startsWithAny(fileNode.getMimeType(), "video/", "audio/")) { + throw new IllegalStateException("No transcription is available yet for this audio/video file. " + + "Transcriptions are generated automatically once the media is processed; tell the user to try again later."); + } else { + throw new IllegalStateException("The document with id %s isn't an audio nor a video file, so it has no transcription." + .formatted(documentId)); + } + } + + public List searchDocuments(String query, + Long spaceId, + String parentFolderId, + Integer offset, + Integer limit, + Boolean isFavorites) throws ObjectNotFoundException, IllegalAccessException { + DocumentTimelineFilter filter = new DocumentTimelineFilter(); + filter.setQuery(query); + if (isFavorites != null && isFavorites.booleanValue()) { + filter.setFavorites(true); + } + if (StringUtils.isNotBlank(parentFolderId)) { + filter.setParentFolderId(parentFolderId); + } else if (spaceId != null && spaceId > 0) { + Space space = spaceService.getSpaceById(String.valueOf(spaceId)); + if (space == null) { + throw new ObjectNotFoundException("Space with id %s not found. Use 'search_my_spaces' tool to check accessible spaces"); + } else if (!spaceService.canViewSpace(space, getCurrentUserName())) { + throw new IllegalAccessException("Space with id %s not accessbile for current user. Use 'search_my_spaces' tool to check accessible spaces"); + } + Identity spaceIdentity = identityManager.getOrCreateSpaceIdentity(space.getPrettyName()); + filter.setOwnerId(spaceIdentity.getIdentityId()); + } + List files = documentFileService.search(filter, + getCurrentUserAclIdentity(), + getInteger(offset, DEFAULT_OFFSET), + getInteger(limit, DEFAULT_LIMIT)); + return files.stream().map(this::toDocumentFileModel).toList(); + } + + private UserModel toUserModel(long identityId) { + Identity identity = identityManager.getIdentity(identityId); + if (identity == null || !identity.isUser()) { + return null; + } else { + return toUserModel(identity.getRemoteId()); + } + } + + private UserModel toUserModel(String username) { + if (StringUtils.isBlank(username)) { + return null; + } + return UserToolUtils.toUserModel(identityManager, + profilePropertyService, + userAcl, + translationService, + portalConfigService, + username, + getCurrentUserName(), + getCurrentUserLocale(), + true); + } + + @SneakyThrows + private String getUrl(AbstractNode abstractNode) { + if (abstractNode instanceof FileNode) { + return FILE_URL_FORMAT.formatted(abstractNode.getId()); + } else { + return FOLDER_URL_FORMAT.formatted(abstractNode.getId()); + } + } + + private AbstractNode checkCanAccessDocument(String documentId) throws IllegalAccessException, ObjectNotFoundException { + checkDocumentIdParameter(documentId); + return documentFileService.getDocumentById(documentId, getCurrentUserName()); + } + + private void checkDocumentIdParameter(String documentId) { + if (StringUtils.isBlank(documentId)) { + throw new IllegalArgumentException(""" + The 'documentId' parameter is mandatory. + Ensure to retrieve the id from the expression '/document:ID'. + Example with documentId = be0688cd7f00010134384ab7e1b15a48, The expression would be /document:be0688cd7f00010134384ab7e1b15a48 + """); + } + } + + private DocumentModel toDocumentModel(AbstractNode document) { + if (document instanceof FileNode fileNode) { + return toDocumentFileModel(fileNode); + } else { + return toDocumentFolderModel(document); + } + } + + private DocumentFileModel toDocumentFileModel(FileNode fileNode) { + String documentId = fileNode.getId(); + return new DocumentFileModel(documentId, + fileNode.getName(), + fileNode.getPath(), + getUrl(fileNode), + fileNode.getParentFolderId(), + formatDate(fileNode.getCreatedDate()), + toUserModel(fileNode.getCreatorUserName()), + fileNode.getSize(), + fileNode.getMimeType(), + getAudioTranscription(documentId), + fileNode.getDescription(), + formatDate(fileNode.getModifiedDate()), + toUserModel(fileNode.getModifierId())); + } + + private String getAudioTranscription(String documentId) { + String audioTranscription; + try { + audioTranscription = documentFileService.getAudioTranscription(documentId, getCurrentUserIdentityId()); + } catch (IllegalAccessException e) { + audioTranscription = null; + } + return audioTranscription; + } + + private DocumentFolderModel toDocumentFolderModel(AbstractNode folder) { + return new DocumentFolderModel(folder.getId(), + folder.getName(), + folder.getPath(), + getUrl(folder), + folder.getParentFolderId(), + formatDate(folder.getCreatedDate()), + toUserModel(folder.getCreatorUserName())); + } + + // FIXME should be removed once Notes refactored and cleaned + private long fixNoteContentId(String contentType, long contentId) throws WikiException, IllegalAccessException { + if (StringUtils.contains(contentType, "note")) { + Page note = ExoContainerContext.getService(NoteService.class) + .getNoteByIdAndLang(contentId, + getCurrentUserAclIdentity(), + null, + getCurrentUserLocale().toLanguageTag()); + if (note != null && note.getLatestVersionId() != null) { + contentId = Long.parseLong(note.getLatestVersionId()); + } + } + return contentId; + } + + // FIXME should be removed once Notes refactored and cleaned + private String fixNoteContentType(String contentType) { + if (StringUtils.contains(contentType, "note")) { + return "WIKI_PAGE_VERSIONS"; + } else { + return contentType; + } + } + + private long getCurrentUserIdentityId() { + return identityManager.getOrCreateUserIdentity(getCurrentUserName()) + .getIdentityId(); + } + +} diff --git a/documents-services/src/main/resources/ai-tool-definitions.json b/documents-services/src/main/resources/ai-tool-definitions.json new file mode 100644 index 000000000..85d5f6d2d --- /dev/null +++ b/documents-services/src/main/resources/ai-tool-definitions.json @@ -0,0 +1,222 @@ +{ + "tools": [ + { + "name": "get_root_folder_by_space", + "title": "Get DMS root folder of a given space", + "description": "Retrieves the root folder associated with a specific space.", + "input_schema": { + "type": "object", + "properties": { + "space_id": { + "type": "integer", + "description": "space id for which the root folder is requested." + } + }, + "required": ["space_id"] + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "get_documents_by_folder_id", + "title": "Get DMS files and/or folders under a given folder", + "description": "Retrieves the files (and/or folders) located under a folder stored in the DMS.", + "input_schema": { + "type": "object", + "properties": { + "folder_id": { + "type": "string" + }, + "files_only": { + "type": "boolean" + }, + "folders_only": { + "type": "boolean" + }, + "offset": { + "type": "integer", + "description": "Pagination offset for result listing." + }, + "limit": { + "type": "integer", + "description": "Maximum number of results to return." + } + }, + "required": ["folder_id"] + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "get_root_folder_for_user", + "title": "Get DMS root personal folder", + "description": "Retrieves the root folder belonging to the currently authenticated user.", + "input_schema": { + "type": "object", + "properties": {} + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "get_document_by_id", + "title": "Get DMS folder or file by its id", + "description": "Fetches the metadata of a document given its unique identifier.", + "input_schema": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "The unique identifier of the document." + } + }, + "required": ["document_id"] + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "get_document_content_by_id", + "title": "Get DMS file content", + "description": "Retrieves the textual or Markdown content of a document by its unique identifier.", + "input_schema": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "The unique identifier of the document." + } + }, + "required": ["document_id"] + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "attach_document_to_content", + "title": "Link DMS file to a content", + "description": "Attach a file to a given content identified by its type (note, new, activity...) and its id", + "input_schema": { + "type": "object", + "properties": { + "document_id": { "type": "string" }, + "content_type": { "type": "string" }, + "content_id": { "type": "integer" } + }, + "required": ["document_id", "content_type", "content_id"] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "require_approval": true + }, + { + "name": "update_document_description", + "title": "Update DMS file summary", + "description": "Updates the file summary. This will not change the content of the file.", + "input_schema": { + "type": "object", + "properties": { + "document_id": { + "type": "string" + }, + "html_description": { + "type": "string" + } + }, + "required": ["document_id", "html_description"] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false + }, + "require_approval": true + }, + { + "name": "get_document_transcription_by_id", + "title": "Transcript an Audio DMS file", + "description": "Get the file transcription when it's an audio/video media content", + "input_schema": { + "type": "object", + "properties": { + "document_id": { + "type": "string" + } + }, + "required": ["document_id"] + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "search_documents", + "title": "Search for files", + "description": "Searches for documents matching the specified query and optional filters.", + "input_schema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Full-text search query string." + }, + "space_id": { + "type": "integer", + "description": "Optional space identifier to restrict search scope." + }, + "parent_folder_id": { + "type": "string", + "description": "Optional parent folder ID to filter results." + }, + "offset": { + "type": "integer", + "description": "Pagination offset for result listing." + }, + "limit": { + "type": "integer", + "description": "Maximum number of results to return." + }, + "is_favorites": { + "type": "boolean", + "description": "If true, restricts results to favorite documents." + } + }, + "required": ["query"] + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + } + ] +} diff --git a/pom.xml b/pom.xml index b215f24b4..647716532 100644 --- a/pom.xml +++ b/pom.xml @@ -29,6 +29,8 @@ 7.3.x-ai-contribution-SNAPSHOT + + 7.3.x-ai-contribution-SNAPSHOT 2.11.5 exoplatform From d9a3577686e71385d2f681f2deb81a87e3f98aed Mon Sep 17 00:00:00 2001 From: Benjamin Mestrallet Date: Mon, 6 Jul 2026 21:03:46 +0100 Subject: [PATCH 03/15] fix: Move Document MCP model records into the Documents add-on The relocated DocumentMcpTool imported DocumentModel / DocumentFileModel / DocumentFolderModel from org.exoplatform.documents.model, but those three records were left behind in the enterprise edition, so documents-services failed to compile ("cannot find symbol"). Bring them into the add-on under org.exoplatform.documents.mcp.model (co-located with the tool, mirroring the poll add-on's io.meeds.poll.mcp.model layout) instead of splitting the org.exoplatform.documents.model package across the api and services jars. Update the imports in DocumentMcpTool accordingly. Add DocumentMcpToolTest (13 cases, JUnit 4 like the rest of the module) so the new tool methods keep the module above its 0.55 coverage gate. No AI (io.meeds.ai / ai-agent-service) dependency is introduced. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../documents/mcp/DocumentMcpTool.java | 6 +- .../mcp/model/DocumentFileModel.java | 72 +++++ .../mcp/model/DocumentFolderModel.java | 44 +++ .../documents/mcp/model/DocumentModel.java | 53 ++++ .../documents/mcp/DocumentMcpToolTest.java | 258 ++++++++++++++++++ 5 files changed, 430 insertions(+), 3 deletions(-) create mode 100644 documents-services/src/main/java/org/exoplatform/documents/mcp/model/DocumentFileModel.java create mode 100644 documents-services/src/main/java/org/exoplatform/documents/mcp/model/DocumentFolderModel.java create mode 100644 documents-services/src/main/java/org/exoplatform/documents/mcp/model/DocumentModel.java create mode 100644 documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java diff --git a/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java b/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java index 3544abecb..958de7491 100644 --- a/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java +++ b/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java @@ -28,11 +28,11 @@ import org.exoplatform.commons.exception.ObjectNotFoundException; import org.exoplatform.container.ExoContainerContext; import org.exoplatform.documents.constant.FileListingType; +import org.exoplatform.documents.mcp.model.DocumentFileModel; +import org.exoplatform.documents.mcp.model.DocumentFolderModel; +import org.exoplatform.documents.mcp.model.DocumentModel; import org.exoplatform.documents.model.AbstractNode; -import org.exoplatform.documents.model.DocumentFileModel; import org.exoplatform.documents.model.DocumentFolderFilter; -import org.exoplatform.documents.model.DocumentFolderModel; -import org.exoplatform.documents.model.DocumentModel; import org.exoplatform.documents.model.DocumentTimelineFilter; import org.exoplatform.documents.model.FileNode; import org.exoplatform.documents.model.FolderNode; diff --git a/documents-services/src/main/java/org/exoplatform/documents/mcp/model/DocumentFileModel.java b/documents-services/src/main/java/org/exoplatform/documents/mcp/model/DocumentFileModel.java new file mode 100644 index 000000000..11ac16f20 --- /dev/null +++ b/documents-services/src/main/java/org/exoplatform/documents/mcp/model/DocumentFileModel.java @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2026 eXo Platform SAS. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.exoplatform.documents.mcp.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; + +import io.meeds.mcp.server.tool.model.UserModel; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@EqualsAndHashCode(callSuper = true) +@JsonInclude(Include.NON_EMPTY) +public class DocumentFileModel extends DocumentModel { + + private long size; + + @JsonProperty("mime_type") + private String mimeType; + + private String transcription; + + private String description; + + @JsonProperty("updated_date") + private String updatedDate; + + @JsonProperty("last_updater_user") + private UserModel lastUpdaterUser; + + public DocumentFileModel(String id, // NOSONAR + String name, + String path, + String url, + String parentFolderId, + String createdDate, + UserModel creatorUser, + long size, + String mimeType, + String transcription, + String description, + String updatedDate, + UserModel lastUpdaterUser) { + super(id, name, path, url, parentFolderId, createdDate, creatorUser); + this.size = size; + this.mimeType = mimeType; + this.transcription = transcription; + this.description = description; + this.updatedDate = updatedDate; + this.lastUpdaterUser = lastUpdaterUser; + } + +} diff --git a/documents-services/src/main/java/org/exoplatform/documents/mcp/model/DocumentFolderModel.java b/documents-services/src/main/java/org/exoplatform/documents/mcp/model/DocumentFolderModel.java new file mode 100644 index 000000000..2989b4a2c --- /dev/null +++ b/documents-services/src/main/java/org/exoplatform/documents/mcp/model/DocumentFolderModel.java @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2026 eXo Platform SAS. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.exoplatform.documents.mcp.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; + +import io.meeds.mcp.server.tool.model.UserModel; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@EqualsAndHashCode(callSuper = true) +@JsonInclude(Include.NON_EMPTY) +public class DocumentFolderModel extends DocumentModel { + + public DocumentFolderModel(String id, + String name, + String path, + String url, + String parentFolderId, + String createdDate, + UserModel creatorUser) { + super(id, name, path, url, parentFolderId, createdDate, creatorUser); + } + +} diff --git a/documents-services/src/main/java/org/exoplatform/documents/mcp/model/DocumentModel.java b/documents-services/src/main/java/org/exoplatform/documents/mcp/model/DocumentModel.java new file mode 100644 index 000000000..ce30a228a --- /dev/null +++ b/documents-services/src/main/java/org/exoplatform/documents/mcp/model/DocumentModel.java @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2026 eXo Platform SAS. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.exoplatform.documents.mcp.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; + +import io.meeds.mcp.server.tool.model.UserModel; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@JsonInclude(Include.NON_EMPTY) +public abstract class DocumentModel { + + @JsonProperty("document_id") + private String id; + + private String name; + + private String path; + + private String url; + + @JsonProperty("parent_folder_id") + private String parentFolderId; + + @JsonProperty("created_date") + private String createdDate; + + @JsonProperty("created_username") + private UserModel createdUser; + +} diff --git a/documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java b/documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java new file mode 100644 index 000000000..1d0113a32 --- /dev/null +++ b/documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java @@ -0,0 +1,258 @@ +/* + * Copyright (C) 2026 eXo Platform SAS. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.exoplatform.documents.mcp; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Locale; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; + +import org.exoplatform.commons.exception.ObjectNotFoundException; +import org.exoplatform.documents.mcp.model.DocumentFileModel; +import org.exoplatform.documents.mcp.model.DocumentFolderModel; +import org.exoplatform.documents.mcp.model.DocumentModel; +import org.exoplatform.documents.model.AbstractNode; +import org.exoplatform.documents.model.FileNode; +import org.exoplatform.documents.model.FolderNode; +import org.exoplatform.documents.service.DocumentFileService; +import org.exoplatform.portal.config.UserACL; +import org.exoplatform.portal.config.UserPortalConfigService; +import org.exoplatform.services.attachments.service.AttachmentService; +import org.exoplatform.services.security.Identity; +import org.exoplatform.social.core.manager.IdentityManager; +import org.exoplatform.social.core.profileproperty.ProfilePropertyService; +import org.exoplatform.social.core.space.model.Space; +import org.exoplatform.social.core.space.spi.SpaceService; + +import io.meeds.social.translation.service.TranslationService; + +public class DocumentMcpToolTest { + + private static final String USERNAME = "testuser1"; + + private static final long USER_IDENTITY_ID = 100L; + + private static final long SPACE_ID = 3L; + + private static final String DOCUMENT_ID = "be0688cd7f00010134384ab7e1b15a48"; + + private static final String FOLDER_ID = "folder-42"; + + private DocumentFileService documentFileService; + + private AttachmentService attachmentService; + + private IdentityManager identityManager; + + private SpaceService spaceService; + + private Identity currentIdentity; + + private DocumentMcpTool documentMcpTool; + + @Before + public void setUp() { + documentFileService = Mockito.mock(DocumentFileService.class); + attachmentService = Mockito.mock(AttachmentService.class); + identityManager = Mockito.mock(IdentityManager.class); + spaceService = Mockito.mock(SpaceService.class); + TranslationService translationService = Mockito.mock(TranslationService.class); + ProfilePropertyService profilePropertyService = Mockito.mock(ProfilePropertyService.class); + UserACL userAcl = Mockito.mock(UserACL.class); + UserPortalConfigService portalConfigService = Mockito.mock(UserPortalConfigService.class); + currentIdentity = new Identity(USERNAME); + + org.exoplatform.social.core.identity.model.Identity socialIdentity = + new org.exoplatform.social.core.identity.model.Identity(String.valueOf(USER_IDENTITY_ID)); + lenient().when(identityManager.getOrCreateUserIdentity(USERNAME)).thenReturn(socialIdentity); + + documentMcpTool = new DocumentMcpTool(documentFileService, + attachmentService, + identityManager, + spaceService, + translationService, + profilePropertyService, + userAcl, + portalConfigService) { + @Override + public Identity getCurrentUserAclIdentity() { + return currentIdentity; + } + + @Override + public Locale getCurrentUserLocale() { + return Locale.ENGLISH; + } + }; + } + + private FileNode fileNode(String id, String mimeType) { + FileNode fileNode = new FileNode(); + fileNode.setId(id); + fileNode.setName("report.pdf"); + fileNode.setPath("/documents/report.pdf"); + fileNode.setMimeType(mimeType); + fileNode.setSize(2048L); + return fileNode; + } + + private FolderNode folderNode(String id) { + FolderNode folderNode = new FolderNode(); + folderNode.setId(id); + folderNode.setName("Projects"); + folderNode.setPath("/documents/Projects"); + return folderNode; + } + + @Test + public void getRootFolderBySpace() throws Exception { + when(documentFileService.getSpaceRootFolder(eq(SPACE_ID), eq(currentIdentity))).thenReturn(folderNode(FOLDER_ID)); + + DocumentFolderModel model = documentMcpTool.getRootFolderBySpace(SPACE_ID); + + assertNotNull(model); + assertEquals(FOLDER_ID, model.getId()); + assertEquals("Projects", model.getName()); + assertNotNull(model.getUrl()); + } + + @Test + public void getRootFolderForUser() { + when(documentFileService.getPersonalRootFolder(eq(currentIdentity))).thenReturn(folderNode(FOLDER_ID)); + + DocumentFolderModel model = documentMcpTool.getRootFolderForUser(); + + assertNotNull(model); + assertEquals(FOLDER_ID, model.getId()); + } + + @Test + public void getDocumentById() throws Exception { + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(fileNode(DOCUMENT_ID, "application/pdf")); + + DocumentModel model = documentMcpTool.getDocumentById(DOCUMENT_ID); + + assertNotNull(model); + assertEquals(DOCUMENT_ID, model.getId()); + } + + @Test + public void getDocumentByIdWithBlankIdFails() { + assertThrows(IllegalArgumentException.class, () -> documentMcpTool.getDocumentById(" ")); + } + + @Test + public void getDocumentContentById() throws Exception { + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(fileNode(DOCUMENT_ID, "text/plain")); + when(documentFileService.getFileContentAsText(DOCUMENT_ID)).thenReturn("Hello world"); + + assertEquals("Hello world", documentMcpTool.getDocumentContentById(DOCUMENT_ID)); + } + + @Test + public void getDocumentTranscriptionById() throws Exception { + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(fileNode(DOCUMENT_ID, "audio/mp3")); + when(documentFileService.getAudioTranscription(eq(DOCUMENT_ID), anyLong())).thenReturn("the transcription"); + + assertEquals("the transcription", documentMcpTool.getDocumentTranscriptionById(DOCUMENT_ID)); + } + + @Test + public void getDocumentTranscriptionByIdWhenPendingFails() throws Exception { + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(fileNode(DOCUMENT_ID, "video/mp4")); + when(documentFileService.getAudioTranscription(eq(DOCUMENT_ID), anyLong())).thenReturn(null); + + assertThrows(IllegalStateException.class, () -> documentMcpTool.getDocumentTranscriptionById(DOCUMENT_ID)); + } + + @Test + public void getDocumentsByFolderId() throws Exception { + List items = List.of(fileNode(DOCUMENT_ID, "application/pdf"), folderNode(FOLDER_ID)); + Mockito.>when(documentFileService.getDocumentItems(any(), any(), anyInt(), anyInt(), anyLong(), anyBoolean())) + .thenReturn(items); + + List models = documentMcpTool.getDocumentsByFolderId(FOLDER_ID, null, null, null, null); + + assertNotNull(models); + assertEquals(2, models.size()); + } + + @Test + public void searchDocuments() throws Exception { + when(documentFileService.search(any(org.exoplatform.documents.model.DocumentTimelineFilter.class), + eq(currentIdentity), + anyInt(), + anyInt())).thenReturn(List.of(fileNode(DOCUMENT_ID, "application/pdf"))); + + List models = documentMcpTool.searchDocuments("report", null, null, null, null, null); + + assertNotNull(models); + assertEquals(1, models.size()); + assertEquals(DOCUMENT_ID, models.get(0).getId()); + } + + @Test + public void searchDocumentsWithUnknownSpaceFails() { + when(spaceService.getSpaceById(String.valueOf(SPACE_ID))).thenReturn(null); + + assertThrows(ObjectNotFoundException.class, + () -> documentMcpTool.searchDocuments("report", SPACE_ID, null, null, null, null)); + } + + @Test + public void searchDocumentsInSpaceNotViewableFails() { + Space space = new Space(); + space.setId(String.valueOf(SPACE_ID)); + space.setPrettyName("engineering"); + when(spaceService.getSpaceById(String.valueOf(SPACE_ID))).thenReturn(space); + when(spaceService.canViewSpace(eq(space), eq(USERNAME))).thenReturn(false); + + assertThrows(IllegalAccessException.class, + () -> documentMcpTool.searchDocuments("report", SPACE_ID, null, null, null, null)); + } + + @Test + public void updateDocumentDescription() throws Exception { + when(documentFileService.getRootFolderOwnerId(DOCUMENT_ID)).thenReturn(55L); + + documentMcpTool.updateDocumentDescription(DOCUMENT_ID, "

new

"); + + verify(documentFileService).updateDocumentDescription(eq(55L), eq(DOCUMENT_ID), eq("

new

"), eq(USER_IDENTITY_ID)); + } + + @Test + public void attachDocumentToContent() throws Exception { + documentMcpTool.attachDocumentToContent(DOCUMENT_ID, "activity", 77L); + + verify(attachmentService).linkAttachmentToEntity(eq(USER_IDENTITY_ID), eq(77L), eq("activity"), eq(DOCUMENT_ID)); + } + +} From 2a61785537f0cb2b44ba3fe1ceec2431fed0e1f7 Mon Sep 17 00:00:00 2001 From: Benjamin Mestrallet Date: Mon, 6 Jul 2026 23:18:01 +0100 Subject: [PATCH 04/15] feat: Add v1 batch of Documents MCP tools (folders, versions, content management) Extend DocumentMcpTool with 15 new MCP tools acting as the current user (ACL enforced by DocumentFileService): Reads: list_folder_children, get_folder_breadcrumb, get_folder_tree, list_document_versions, get_documents_size. Writes (require_approval): create_folder, rename_document, move_document, copy_document, duplicate_document, delete_document (trash only, delay=0), undo_delete_document, restore_document_version, update_version_summary, set_document_visibility. Adds thin model records (BreadcrumbItemModel, DocumentVersionModel, DocumentTreeItemModel, DocumentsSizeModel), matching ai-tool-definitions.json entries, and DocumentMcpToolTest coverage. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../documents/mcp/DocumentMcpTool.java | 216 ++++++++++ .../mcp/model/BreadcrumbItemModel.java | 28 ++ .../mcp/model/DocumentTreeItemModel.java | 33 ++ .../mcp/model/DocumentVersionModel.java | 36 ++ .../mcp/model/DocumentsSizeModel.java | 29 ++ .../main/resources/ai-tool-definitions.json | 372 ++++++++++++++++++ .../documents/mcp/DocumentMcpToolTest.java | 250 ++++++++++++ 7 files changed, 964 insertions(+) create mode 100644 documents-services/src/main/java/org/exoplatform/documents/mcp/model/BreadcrumbItemModel.java create mode 100644 documents-services/src/main/java/org/exoplatform/documents/mcp/model/DocumentTreeItemModel.java create mode 100644 documents-services/src/main/java/org/exoplatform/documents/mcp/model/DocumentVersionModel.java create mode 100644 documents-services/src/main/java/org/exoplatform/documents/mcp/model/DocumentsSizeModel.java diff --git a/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java b/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java index 958de7491..58ca92a22 100644 --- a/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java +++ b/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java @@ -25,17 +25,26 @@ import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; +import org.exoplatform.commons.ObjectAlreadyExistsException; import org.exoplatform.commons.exception.ObjectNotFoundException; import org.exoplatform.container.ExoContainerContext; import org.exoplatform.documents.constant.FileListingType; +import org.exoplatform.documents.mcp.model.BreadcrumbItemModel; import org.exoplatform.documents.mcp.model.DocumentFileModel; import org.exoplatform.documents.mcp.model.DocumentFolderModel; import org.exoplatform.documents.mcp.model.DocumentModel; +import org.exoplatform.documents.mcp.model.DocumentTreeItemModel; +import org.exoplatform.documents.mcp.model.DocumentVersionModel; +import org.exoplatform.documents.mcp.model.DocumentsSizeModel; import org.exoplatform.documents.model.AbstractNode; +import org.exoplatform.documents.model.BreadCrumbItem; import org.exoplatform.documents.model.DocumentFolderFilter; import org.exoplatform.documents.model.DocumentTimelineFilter; +import org.exoplatform.documents.model.DocumentsSize; import org.exoplatform.documents.model.FileNode; +import org.exoplatform.documents.model.FileVersion; import org.exoplatform.documents.model.FolderNode; +import org.exoplatform.documents.model.FullTreeItem; import org.exoplatform.documents.service.DocumentFileService; import org.exoplatform.portal.config.UserACL; import org.exoplatform.portal.config.UserPortalConfigService; @@ -208,6 +217,175 @@ public List searchDocuments(String query, return files.stream().map(this::toDocumentFileModel).toList(); } + // --------------------------------------------------------------------------- + // Folder navigation and metadata (reads) + // --------------------------------------------------------------------------- + + public List listFolderChildren(String folderId, + Integer offset, + Integer limit) throws ObjectNotFoundException, IllegalAccessException { + checkFolderIdParameter(folderId); + DocumentFolderFilter filter = new DocumentFolderFilter(folderId, null, null, null); + List children = documentFileService.getFolderChildNodes(filter, + getInteger(offset, DEFAULT_OFFSET), + getInteger(limit, DEFAULT_LIMIT), + getCurrentUserIdentityId()); + return children.stream().map(this::toDocumentModel).toList(); + } + + public List getFolderBreadcrumb(String folderId) throws ObjectNotFoundException, + IllegalAccessException { + checkFolderIdParameter(folderId); + AbstractNode folder = getNode(folderId); + List breadcrumb = documentFileService.getBreadcrumb(getOwnerId(folderId), + folderId, + folder.getPath(), + getCurrentUserIdentityId()); + return breadcrumb.stream() + .map(item -> new BreadcrumbItemModel(item.getId(), item.getName(), item.getPath())) + .toList(); + } + + public List getFolderTree(String folderId, + Boolean withChildren) throws ObjectNotFoundException, + IllegalAccessException { + checkFolderIdParameter(folderId); + List tree = documentFileService.getFullTreeData(getOwnerId(folderId), + folderId, + null, + getCurrentUserIdentityId(), + withChildren == null || withChildren.booleanValue(), + false); + return tree.stream().map(this::toTreeItemModel).toList(); + } + + public List listDocumentVersions(String documentId) throws IllegalAccessException, + ObjectNotFoundException { + checkCanAccessDocument(documentId); + List versions = documentFileService.getFileVersions(documentId, getCurrentUserName()); + return versions.stream().map(this::toVersionModel).toList(); + } + + public DocumentsSizeModel getDocumentsSize(Long ownerId) throws ObjectNotFoundException, IllegalAccessException { + long resolvedOwnerId = ownerId == null || ownerId <= 0 ? getCurrentUserIdentityId() : ownerId; + DocumentsSize size = documentFileService.getDocumentsSizeStat(resolvedOwnerId, getCurrentUserIdentityId()); + return new DocumentsSizeModel(size.getOwnerId(), size.getToSize()); + } + + // --------------------------------------------------------------------------- + // Content management (writes, require approval) + // --------------------------------------------------------------------------- + + public DocumentModel createFolder(String parentFolderId, + String name) throws IllegalAccessException, ObjectNotFoundException { + checkFolderIdParameter(parentFolderId); + if (StringUtils.isBlank(name)) { + throw new IllegalArgumentException("The 'name' parameter is mandatory to create a folder. Ask the user for a folder name."); + } + AbstractNode parent = getNode(parentFolderId); + try { + AbstractNode folder = documentFileService.createFolder(getOwnerId(parentFolderId), + parentFolderId, + parent.getPath(), + name, + getCurrentUserIdentityId()); + return toDocumentModel(folder); + } catch (ObjectAlreadyExistsException e) { + throw new IllegalStateException("A folder named '%s' already exists under this parent folder. Tell the user to pick a different name." + .formatted(name)); + } + } + + public void renameDocument(String documentId, String newName) throws IllegalAccessException, ObjectNotFoundException { + checkDocumentIdParameter(documentId); + if (StringUtils.isBlank(newName)) { + throw new IllegalArgumentException("The 'newName' parameter is mandatory to rename a document."); + } + try { + documentFileService.renameDocument(getOwnerId(documentId), documentId, newName, getCurrentUserIdentityId()); + } catch (ObjectAlreadyExistsException e) { + throw new IllegalStateException("A document named '%s' already exists in the same folder. Tell the user to pick a different name." + .formatted(newName)); + } + } + + public void moveDocument(String documentId, + String destinationFolderId, + String conflictAction) throws IllegalAccessException, ObjectNotFoundException { + checkDocumentIdParameter(documentId); + if (StringUtils.isBlank(destinationFolderId)) { + throw new IllegalArgumentException("The 'destinationFolderId' parameter is mandatory. Call get_document_by_id or get_documents_by_folder_id to find the target folder id."); + } + AbstractNode destination = getNode(destinationFolderId); + try { + documentFileService.moveDocument(getOwnerId(documentId), + documentId, + destination.getPath(), + getCurrentUserIdentityId(), + StringUtils.isBlank(conflictAction) ? "rename" : conflictAction); + } catch (ObjectAlreadyExistsException e) { + throw new IllegalStateException("A document with the same name already exists in the destination folder. Retry with conflict_action set to 'rename'."); + } + } + + public DocumentModel copyDocument(String documentId, + String destinationFolderId) throws IllegalAccessException, ObjectNotFoundException { + checkDocumentIdParameter(documentId); + if (StringUtils.isBlank(destinationFolderId)) { + throw new IllegalArgumentException("The 'destinationFolderId' parameter is mandatory. Call get_document_by_id or get_documents_by_folder_id to find the target folder id."); + } + AbstractNode copy = documentFileService.copyDocument(documentId, destinationFolderId, getCurrentUserIdentityId()); + return toDocumentModel(copy); + } + + public DocumentModel duplicateDocument(String documentId, + String prefix) throws IllegalAccessException, ObjectNotFoundException { + checkDocumentIdParameter(documentId); + AbstractNode duplicate = documentFileService.duplicateDocument(getOwnerId(documentId), + documentId, + prefix, + getCurrentUserIdentityId()); + return toDocumentModel(duplicate); + } + + public void deleteDocument(String documentId) throws IllegalAccessException, ObjectNotFoundException { + AbstractNode document = checkCanAccessDocument(documentId); + // delay = 0: move to trash immediately. favorite = false: the flag only + // triggers removal from the favorites list, which we don't manage here. + documentFileService.deleteDocument(document.getPath(), documentId, false, 0, getCurrentUserIdentityId()); + } + + public void undoDeleteDocument(String documentId) { + checkDocumentIdParameter(documentId); + documentFileService.undoDeleteDocument(documentId, getCurrentUserIdentityId()); + } + + public DocumentVersionModel restoreDocumentVersion(String versionId) { + if (StringUtils.isBlank(versionId)) { + throw new IllegalArgumentException("The 'versionId' parameter is mandatory. Call list_document_versions first to get a version_id."); + } + FileVersion version = documentFileService.restoreVersion(versionId, getCurrentUserName()); + return toVersionModel(version); + } + + public DocumentVersionModel updateVersionSummary(String documentId, String versionId, String summary) { + checkDocumentIdParameter(documentId); + if (StringUtils.isBlank(versionId)) { + throw new IllegalArgumentException("The 'versionId' parameter is mandatory. Call list_document_versions first to get a version_id."); + } + FileVersion version = documentFileService.updateVersionSummary(documentId, versionId, summary, getCurrentUserName()); + return toVersionModel(version); + } + + @SneakyThrows + public void setDocumentVisibility(String documentId, Boolean hidden) throws IllegalAccessException { + checkDocumentIdParameter(documentId); + if (hidden == null) { + throw new IllegalArgumentException("The 'hidden' parameter is mandatory (true to hide the document, false to make it visible)."); + } + documentFileService.setDocumentVisibility(getOwnerId(documentId), documentId, hidden, getCurrentUserIdentityId()); + } + private UserModel toUserModel(long identityId) { Identity identity = identityManager.getIdentity(identityId); if (identity == null || !identity.isUser()) { @@ -246,6 +424,44 @@ private AbstractNode checkCanAccessDocument(String documentId) throws IllegalAcc return documentFileService.getDocumentById(documentId, getCurrentUserName()); } + private AbstractNode getNode(String documentId) throws IllegalAccessException, ObjectNotFoundException { + return documentFileService.getDocumentById(documentId, getCurrentUserName()); + } + + private long getOwnerId(String documentId) { + return documentFileService.getRootFolderOwnerId(documentId); + } + + private DocumentTreeItemModel toTreeItemModel(FullTreeItem item) { + List children = item.getChildren() == null ? null + : item.getChildren() + .stream() + .map(this::toTreeItemModel) + .toList(); + return new DocumentTreeItemModel(item.getId(), item.getName(), item.getPath(), children); + } + + private DocumentVersionModel toVersionModel(FileVersion version) { + return new DocumentVersionModel(version.getId(), + version.getVersionNumber(), + version.getTitle(), + version.getSummary(), + version.getAuthor(), + version.getAuthorFullName(), + formatDate(version.getCreatedDate()), + version.isCurrent(), + version.getSize()); + } + + private void checkFolderIdParameter(String folderId) { + if (StringUtils.isBlank(folderId)) { + throw new IllegalArgumentException(""" + The 'folderId' parameter is mandatory. + Call get_root_folder_for_user, get_root_folder_by_space or get_documents_by_folder_id first to obtain a folder id. + """); + } + } + private void checkDocumentIdParameter(String documentId) { if (StringUtils.isBlank(documentId)) { throw new IllegalArgumentException(""" diff --git a/documents-services/src/main/java/org/exoplatform/documents/mcp/model/BreadcrumbItemModel.java b/documents-services/src/main/java/org/exoplatform/documents/mcp/model/BreadcrumbItemModel.java new file mode 100644 index 000000000..1220076d9 --- /dev/null +++ b/documents-services/src/main/java/org/exoplatform/documents/mcp/model/BreadcrumbItemModel.java @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2026 eXo Platform SAS. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.exoplatform.documents.mcp.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * A single ancestor entry of a folder/file breadcrumb (from root to the node). + */ +@JsonInclude(Include.NON_EMPTY) +public record BreadcrumbItemModel(@JsonProperty("document_id") String id, String name, String path) { +} diff --git a/documents-services/src/main/java/org/exoplatform/documents/mcp/model/DocumentTreeItemModel.java b/documents-services/src/main/java/org/exoplatform/documents/mcp/model/DocumentTreeItemModel.java new file mode 100644 index 000000000..d968c7340 --- /dev/null +++ b/documents-services/src/main/java/org/exoplatform/documents/mcp/model/DocumentTreeItemModel.java @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2026 eXo Platform SAS. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.exoplatform.documents.mcp.model; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * A node of the folder tree, holding its child folders recursively. + */ +@JsonInclude(Include.NON_EMPTY) +public record DocumentTreeItemModel(@JsonProperty("document_id") String id, + String name, + String path, + List children) { +} diff --git a/documents-services/src/main/java/org/exoplatform/documents/mcp/model/DocumentVersionModel.java b/documents-services/src/main/java/org/exoplatform/documents/mcp/model/DocumentVersionModel.java new file mode 100644 index 000000000..ac70741e3 --- /dev/null +++ b/documents-services/src/main/java/org/exoplatform/documents/mcp/model/DocumentVersionModel.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2026 eXo Platform SAS. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.exoplatform.documents.mcp.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * A single version entry of a versioned document. + */ +@JsonInclude(Include.NON_EMPTY) +public record DocumentVersionModel(@JsonProperty("version_id") String id, + @JsonProperty("version_number") int versionNumber, + String title, + String summary, + String author, + @JsonProperty("author_full_name") String authorFullName, + @JsonProperty("created_date") String createdDate, + @JsonProperty("is_current") boolean current, + long size) { +} diff --git a/documents-services/src/main/java/org/exoplatform/documents/mcp/model/DocumentsSizeModel.java b/documents-services/src/main/java/org/exoplatform/documents/mcp/model/DocumentsSizeModel.java new file mode 100644 index 000000000..9cee41b36 --- /dev/null +++ b/documents-services/src/main/java/org/exoplatform/documents/mcp/model/DocumentsSizeModel.java @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2026 eXo Platform SAS. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.exoplatform.documents.mcp.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Aggregated storage size, in bytes, of a user or space drive. + */ +@JsonInclude(Include.NON_EMPTY) +public record DocumentsSizeModel(@JsonProperty("owner_id") long ownerId, + @JsonProperty("size_in_bytes") long sizeInBytes) { +} diff --git a/documents-services/src/main/resources/ai-tool-definitions.json b/documents-services/src/main/resources/ai-tool-definitions.json index 85d5f6d2d..578340aa2 100644 --- a/documents-services/src/main/resources/ai-tool-definitions.json +++ b/documents-services/src/main/resources/ai-tool-definitions.json @@ -217,6 +217,378 @@ "idempotentHint": true, "openWorldHint": false } + }, + { + "name": "list_folder_children", + "title": "List DMS folder children", + "description": "Lists the direct child files and folders of a folder. Call get_root_folder_for_user, get_root_folder_by_space or get_documents_by_folder_id first to obtain a folder_id.", + "input_schema": { + "type": "object", + "properties": { + "folder_id": { + "type": "string", + "description": "The unique identifier of the folder to list." + }, + "offset": { + "type": "integer", + "description": "Pagination offset for result listing." + }, + "limit": { + "type": "integer", + "description": "Maximum number of results to return." + } + }, + "required": ["folder_id"] + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "get_folder_breadcrumb", + "title": "Get DMS folder breadcrumb", + "description": "Returns the ancestor path (breadcrumb, from the drive root down to the folder) of a folder. Call get_documents_by_folder_id first to get a folder_id.", + "input_schema": { + "type": "object", + "properties": { + "folder_id": { + "type": "string", + "description": "The unique identifier of the folder." + } + }, + "required": ["folder_id"] + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "get_folder_tree", + "title": "Get DMS folder tree", + "description": "Returns the nested folder tree rooted at a folder. Call get_root_folder_for_user or get_documents_by_folder_id first to get a folder_id.", + "input_schema": { + "type": "object", + "properties": { + "folder_id": { + "type": "string", + "description": "The unique identifier of the root folder of the tree." + }, + "with_children": { + "type": "boolean", + "description": "If true (default), include the child folders recursively." + } + }, + "required": ["folder_id"] + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "list_document_versions", + "title": "List DMS file versions", + "description": "Lists the versions of a versioned file, newest last. Call get_document_by_id or get_documents_by_folder_id first to get a document_id.", + "input_schema": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "The unique identifier of the file." + } + }, + "required": ["document_id"] + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "get_documents_size", + "title": "Get DMS drive size", + "description": "Returns the total storage size (in bytes) of a user or space drive. When owner_id is omitted, returns the size of the current user's personal drive.", + "input_schema": { + "type": "object", + "properties": { + "owner_id": { + "type": "integer", + "description": "Identity id of the user or space whose drive size is requested. Optional; defaults to the current user." + } + } + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "create_folder", + "title": "Create a DMS folder", + "description": "Creates a new folder under a parent folder. Call get_root_folder_for_user, get_root_folder_by_space or get_documents_by_folder_id first to get the parent_folder_id.", + "input_schema": { + "type": "object", + "properties": { + "parent_folder_id": { + "type": "string", + "description": "The unique identifier of the parent folder." + }, + "name": { + "type": "string", + "description": "Name of the folder to create." + } + }, + "required": ["parent_folder_id", "name"] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "require_approval": true + }, + { + "name": "rename_document", + "title": "Rename a DMS file or folder", + "description": "Renames a file or folder. Call get_document_by_id or get_documents_by_folder_id first to get a document_id.", + "input_schema": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "The unique identifier of the file or folder to rename." + }, + "new_name": { + "type": "string", + "description": "The new name." + } + }, + "required": ["document_id", "new_name"] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false + }, + "require_approval": true + }, + { + "name": "move_document", + "title": "Move a DMS file or folder", + "description": "Moves a file or folder into a destination folder. Call get_document_by_id or get_documents_by_folder_id first to get the document_id and the destination_folder_id.", + "input_schema": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "The unique identifier of the file or folder to move." + }, + "destination_folder_id": { + "type": "string", + "description": "The unique identifier of the destination folder." + }, + "conflict_action": { + "type": "string", + "description": "Behaviour when a document with the same name exists in the destination. Defaults to 'rename'." + } + }, + "required": ["document_id", "destination_folder_id"] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "require_approval": true + }, + { + "name": "copy_document", + "title": "Copy a DMS file or folder", + "description": "Copies a file or folder into a destination folder. Call get_document_by_id or get_documents_by_folder_id first to get the document_id and the destination_folder_id.", + "input_schema": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "The unique identifier of the file or folder to copy." + }, + "destination_folder_id": { + "type": "string", + "description": "The unique identifier of the destination folder." + } + }, + "required": ["document_id", "destination_folder_id"] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "require_approval": true + }, + { + "name": "duplicate_document", + "title": "Duplicate a DMS file or folder", + "description": "Creates a copy of a file or folder in the same parent folder. Call get_document_by_id or get_documents_by_folder_id first to get a document_id.", + "input_schema": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "The unique identifier of the file or folder to duplicate." + }, + "prefix": { + "type": "string", + "description": "Optional prefix added to the duplicated name (e.g. 'Copy of')." + } + }, + "required": ["document_id"] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "require_approval": true + }, + { + "name": "delete_document", + "title": "Delete a DMS file or folder", + "description": "Moves a file or folder to the trash. It can be restored with undo_delete_document. Call get_document_by_id or get_documents_by_folder_id first to get a document_id.", + "input_schema": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "The unique identifier of the file or folder to delete." + } + }, + "required": ["document_id"] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "require_approval": true + }, + { + "name": "undo_delete_document", + "title": "Restore a deleted DMS document", + "description": "Cancels a previous delete_document and restores the document from the trash.", + "input_schema": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "The unique identifier of the previously deleted document." + } + }, + "required": ["document_id"] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "require_approval": true + }, + { + "name": "restore_document_version", + "title": "Restore a DMS file version", + "description": "Restores a previous version of a file as the current one. Call list_document_versions first to get a version_id.", + "input_schema": { + "type": "object", + "properties": { + "version_id": { + "type": "string", + "description": "The unique identifier of the version to restore." + } + }, + "required": ["version_id"] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false + }, + "require_approval": true + }, + { + "name": "update_version_summary", + "title": "Update a DMS file version summary", + "description": "Updates the summary (change note) of a specific file version. Call list_document_versions first to get the version_id; the document_id is the file the version belongs to.", + "input_schema": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "The unique identifier of the file the version belongs to." + }, + "version_id": { + "type": "string", + "description": "The unique identifier of the version to update." + }, + "summary": { + "type": "string", + "description": "The new summary text." + } + }, + "required": ["document_id", "version_id", "summary"] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false + }, + "require_approval": true + }, + { + "name": "set_document_visibility", + "title": "Set DMS document visibility", + "description": "Hides or shows a file or folder. Call get_document_by_id or get_documents_by_folder_id first to get a document_id.", + "input_schema": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "The unique identifier of the file or folder." + }, + "hidden": { + "type": "boolean", + "description": "true to hide the document, false to make it visible." + } + }, + "required": ["document_id", "hidden"] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false + }, + "require_approval": true } ] } diff --git a/documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java b/documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java index 1d0113a32..ac8d1f211 100644 --- a/documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java +++ b/documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java @@ -19,15 +19,18 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.util.Date; import java.util.List; import java.util.Locale; @@ -36,12 +39,20 @@ import org.mockito.Mockito; import org.exoplatform.commons.exception.ObjectNotFoundException; +import org.exoplatform.documents.mcp.model.BreadcrumbItemModel; import org.exoplatform.documents.mcp.model.DocumentFileModel; import org.exoplatform.documents.mcp.model.DocumentFolderModel; import org.exoplatform.documents.mcp.model.DocumentModel; +import org.exoplatform.documents.mcp.model.DocumentTreeItemModel; +import org.exoplatform.documents.mcp.model.DocumentVersionModel; +import org.exoplatform.documents.mcp.model.DocumentsSizeModel; import org.exoplatform.documents.model.AbstractNode; +import org.exoplatform.documents.model.BreadCrumbItem; +import org.exoplatform.documents.model.DocumentsSize; import org.exoplatform.documents.model.FileNode; +import org.exoplatform.documents.model.FileVersion; import org.exoplatform.documents.model.FolderNode; +import org.exoplatform.documents.model.FullTreeItem; import org.exoplatform.documents.service.DocumentFileService; import org.exoplatform.portal.config.UserACL; import org.exoplatform.portal.config.UserPortalConfigService; @@ -255,4 +266,243 @@ public void attachDocumentToContent() throws Exception { verify(attachmentService).linkAttachmentToEntity(eq(USER_IDENTITY_ID), eq(77L), eq("activity"), eq(DOCUMENT_ID)); } + // --------------------------------------------------------------------------- + // New folder-navigation and content-management tools + // --------------------------------------------------------------------------- + + private static final long OWNER_ID = 55L; + + private FileVersion fileVersion() { + FileVersion version = new FileVersion(); + version.setId("version-1"); + version.setVersionNumber(2); + version.setTitle("report.pdf"); + version.setSummary("initial upload"); + version.setAuthor(USERNAME); + version.setAuthorFullName("Test User"); + version.setCreatedDate(new Date()); + version.setCurrent(true); + version.setSize(2048L); + return version; + } + + @Test + public void listFolderChildren() throws Exception { + when(documentFileService.getFolderChildNodes(any(), anyInt(), anyInt(), anyLong())).thenReturn(List.of(fileNode(DOCUMENT_ID, + "application/pdf"), + folderNode(FOLDER_ID))); + + List models = documentMcpTool.listFolderChildren(FOLDER_ID, null, null); + + assertNotNull(models); + assertEquals(2, models.size()); + } + + @Test + public void listFolderChildrenBlankFolderFails() { + assertThrows(IllegalArgumentException.class, () -> documentMcpTool.listFolderChildren(" ", null, null)); + } + + @Test + public void getFolderBreadcrumb() throws Exception { + when(documentFileService.getDocumentById(FOLDER_ID, USERNAME)).thenReturn(folderNode(FOLDER_ID)); + when(documentFileService.getRootFolderOwnerId(FOLDER_ID)).thenReturn(OWNER_ID); + when(documentFileService.getBreadcrumb(eq(OWNER_ID), eq(FOLDER_ID), anyString(), eq(USER_IDENTITY_ID))) + .thenReturn(List.of(new BreadCrumbItem(FOLDER_ID, "Projects", "Projects", "/documents/Projects", false, null))); + + List models = documentMcpTool.getFolderBreadcrumb(FOLDER_ID); + + assertEquals(1, models.size()); + assertEquals(FOLDER_ID, models.get(0).id()); + assertEquals("Projects", models.get(0).name()); + } + + @Test + public void getFolderBreadcrumbUnknownFolderFails() throws Exception { + when(documentFileService.getDocumentById(FOLDER_ID, USERNAME)).thenThrow(new ObjectNotFoundException("not found")); + + assertThrows(ObjectNotFoundException.class, () -> documentMcpTool.getFolderBreadcrumb(FOLDER_ID)); + } + + @Test + public void getFolderTree() throws Exception { + when(documentFileService.getRootFolderOwnerId(FOLDER_ID)).thenReturn(OWNER_ID); + FullTreeItem child = new FullTreeItem("child-1", "Sub", "/documents/Projects/Sub", null, false, String.valueOf(OWNER_ID)); + FullTreeItem root = new FullTreeItem(FOLDER_ID, "Projects", "/documents/Projects", List.of(child), false, String.valueOf(OWNER_ID)); + when(documentFileService.getFullTreeData(eq(OWNER_ID), eq(FOLDER_ID), any(), eq(USER_IDENTITY_ID), anyBoolean(), anyBoolean())) + .thenReturn(List.of(root)); + + List tree = documentMcpTool.getFolderTree(FOLDER_ID, true); + + assertEquals(1, tree.size()); + assertEquals(FOLDER_ID, tree.get(0).id()); + assertEquals(1, tree.get(0).children().size()); + assertEquals("child-1", tree.get(0).children().get(0).id()); + } + + @Test + public void listDocumentVersions() throws Exception { + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(fileNode(DOCUMENT_ID, "application/pdf")); + when(documentFileService.getFileVersions(DOCUMENT_ID, USERNAME)).thenReturn(List.of(fileVersion())); + + List versions = documentMcpTool.listDocumentVersions(DOCUMENT_ID); + + assertEquals(1, versions.size()); + assertEquals("version-1", versions.get(0).id()); + assertTrue(versions.get(0).current()); + } + + @Test + public void getDocumentsSizeDefaultsToCurrentUser() throws Exception { + when(documentFileService.getDocumentsSizeStat(USER_IDENTITY_ID, USER_IDENTITY_ID)).thenReturn(new DocumentsSize(USER_IDENTITY_ID, + 4096L, + 0L, + 0L, + 0L, + true, + 0L)); + + DocumentsSizeModel model = documentMcpTool.getDocumentsSize(null); + + assertNotNull(model); + assertEquals(USER_IDENTITY_ID, model.ownerId()); + assertEquals(4096L, model.sizeInBytes()); + } + + @Test + public void createFolder() throws Exception { + when(documentFileService.getDocumentById(FOLDER_ID, USERNAME)).thenReturn(folderNode(FOLDER_ID)); + when(documentFileService.getRootFolderOwnerId(FOLDER_ID)).thenReturn(OWNER_ID); + when(documentFileService.createFolder(eq(OWNER_ID), eq(FOLDER_ID), eq("/documents/Projects"), eq("New"), eq(USER_IDENTITY_ID))) + .thenReturn(folderNode("new-folder")); + + DocumentModel model = documentMcpTool.createFolder(FOLDER_ID, "New"); + + assertNotNull(model); + assertEquals("new-folder", model.getId()); + } + + @Test + public void createFolderBlankNameFails() { + assertThrows(IllegalArgumentException.class, () -> documentMcpTool.createFolder(FOLDER_ID, " ")); + } + + @Test + public void renameDocument() throws Exception { + when(documentFileService.getRootFolderOwnerId(DOCUMENT_ID)).thenReturn(OWNER_ID); + + documentMcpTool.renameDocument(DOCUMENT_ID, "renamed.pdf"); + + verify(documentFileService).renameDocument(eq(OWNER_ID), eq(DOCUMENT_ID), eq("renamed.pdf"), eq(USER_IDENTITY_ID)); + } + + @Test + public void moveDocumentDefaultsConflictActionToRename() throws Exception { + when(documentFileService.getDocumentById(FOLDER_ID, USERNAME)).thenReturn(folderNode(FOLDER_ID)); + when(documentFileService.getRootFolderOwnerId(DOCUMENT_ID)).thenReturn(OWNER_ID); + + documentMcpTool.moveDocument(DOCUMENT_ID, FOLDER_ID, null); + + verify(documentFileService).moveDocument(eq(OWNER_ID), + eq(DOCUMENT_ID), + eq("/documents/Projects"), + eq(USER_IDENTITY_ID), + eq("rename")); + } + + @Test + public void moveDocumentBlankDestinationFails() { + assertThrows(IllegalArgumentException.class, () -> documentMcpTool.moveDocument(DOCUMENT_ID, " ", null)); + } + + @Test + public void copyDocument() throws Exception { + when(documentFileService.copyDocument(DOCUMENT_ID, FOLDER_ID, USER_IDENTITY_ID)).thenReturn(fileNode("copy-1", "application/pdf")); + + DocumentModel model = documentMcpTool.copyDocument(DOCUMENT_ID, FOLDER_ID); + + assertNotNull(model); + assertEquals("copy-1", model.getId()); + } + + @Test + public void duplicateDocument() throws Exception { + when(documentFileService.getRootFolderOwnerId(DOCUMENT_ID)).thenReturn(OWNER_ID); + when(documentFileService.duplicateDocument(OWNER_ID, DOCUMENT_ID, "Copy of", USER_IDENTITY_ID)).thenReturn(fileNode("dup-1", + "application/pdf")); + + DocumentModel model = documentMcpTool.duplicateDocument(DOCUMENT_ID, "Copy of"); + + assertNotNull(model); + assertEquals("dup-1", model.getId()); + } + + @Test + public void deleteDocumentMovesToTrashImmediately() throws Exception { + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(fileNode(DOCUMENT_ID, "application/pdf")); + + documentMcpTool.deleteDocument(DOCUMENT_ID); + + verify(documentFileService).deleteDocument(eq("/documents/report.pdf"), + eq(DOCUMENT_ID), + eq(false), + eq(0L), + eq(USER_IDENTITY_ID)); + } + + @Test + public void deleteDocumentUnknownFails() throws Exception { + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenThrow(new ObjectNotFoundException("not found")); + + assertThrows(ObjectNotFoundException.class, () -> documentMcpTool.deleteDocument(DOCUMENT_ID)); + } + + @Test + public void undoDeleteDocument() { + documentMcpTool.undoDeleteDocument(DOCUMENT_ID); + + verify(documentFileService).undoDeleteDocument(eq(DOCUMENT_ID), eq(USER_IDENTITY_ID)); + } + + @Test + public void restoreDocumentVersion() { + when(documentFileService.restoreVersion("version-1", USERNAME)).thenReturn(fileVersion()); + + DocumentVersionModel model = documentMcpTool.restoreDocumentVersion("version-1"); + + assertNotNull(model); + assertEquals("version-1", model.id()); + } + + @Test + public void restoreDocumentVersionBlankFails() { + assertThrows(IllegalArgumentException.class, () -> documentMcpTool.restoreDocumentVersion(" ")); + } + + @Test + public void updateVersionSummary() { + FileVersion updated = fileVersion(); + updated.setSummary("new summary"); + when(documentFileService.updateVersionSummary(DOCUMENT_ID, "version-1", "new summary", USERNAME)).thenReturn(updated); + + DocumentVersionModel model = documentMcpTool.updateVersionSummary(DOCUMENT_ID, "version-1", "new summary"); + + assertNotNull(model); + assertEquals("new summary", model.summary()); + } + + @Test + public void setDocumentVisibility() throws Exception { + when(documentFileService.getRootFolderOwnerId(DOCUMENT_ID)).thenReturn(OWNER_ID); + + documentMcpTool.setDocumentVisibility(DOCUMENT_ID, Boolean.TRUE); + + verify(documentFileService).setDocumentVisibility(eq(OWNER_ID), eq(DOCUMENT_ID), eq(Boolean.TRUE), eq(USER_IDENTITY_ID)); + } + + @Test + public void setDocumentVisibilityNullHiddenFails() { + assertThrows(IllegalArgumentException.class, () -> documentMcpTool.setDocumentVisibility(DOCUMENT_ID, null)); + } + } From 5bfd7ac6f6fec66abab33965abae0be5bc3fd2fb Mon Sep 17 00:00:00 2001 From: Benjamin Mestrallet Date: Mon, 6 Jul 2026 23:57:08 +0100 Subject: [PATCH 05/15] fix: create_folder under root folder fails in Documents MCP tools The JCR storage createFolder/getBreadcrumb resolve the parent from the node id (folderId) and treat 'folderPath' as a RELATIVE sub-path from that node (node.getNode(folderPath) / getNodeByPath). DocumentMcpTool was passing the parent node's ABSOLUTE JCR path, so creating a folder under the user's ROOT folder failed with ObjectNotFoundException: Folder with path : /Users/.../Private isn't found. Pass null as folderPath in createFolder and getFolderBreadcrumb since the folder id already identifies the JCR node uniquely. move/delete keep passing absolute paths (the storage uses them as absolute JCR paths). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../documents/mcp/DocumentMcpTool.java | 22 ++++++++++--- .../documents/mcp/DocumentMcpToolTest.java | 31 +++++++++++++++++-- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java b/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java index 58ca92a22..abc46cef2 100644 --- a/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java +++ b/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java @@ -236,10 +236,16 @@ public List listFolderChildren(String folderId, public List getFolderBreadcrumb(String folderId) throws ObjectNotFoundException, IllegalAccessException { checkFolderIdParameter(folderId); - AbstractNode folder = getNode(folderId); + // Validate the folder exists and is readable by the user, so a bad id fails + // with a clean ObjectNotFoundException / IllegalAccessException. + getNode(folderId); + // folderId already identifies the JCR node uniquely; the storage 'folderPath' + // argument is a RELATIVE sub-path resolved *from* that node (getNodeByPath), + // so it must stay empty. Passing the absolute node path makes the storage try + // to navigate further down and fail with "Folder with path ... isn't found". List breadcrumb = documentFileService.getBreadcrumb(getOwnerId(folderId), folderId, - folder.getPath(), + null, getCurrentUserIdentityId()); return breadcrumb.stream() .map(item -> new BreadcrumbItemModel(item.getId(), item.getName(), item.getPath())) @@ -282,11 +288,19 @@ public DocumentModel createFolder(String parentFolderId, if (StringUtils.isBlank(name)) { throw new IllegalArgumentException("The 'name' parameter is mandatory to create a folder. Ask the user for a folder name."); } - AbstractNode parent = getNode(parentFolderId); + // Validate the parent folder exists and is readable by the user, so a bad id + // fails with a clean ObjectNotFoundException / IllegalAccessException before + // reaching the JCR storage. + getNode(parentFolderId); try { + // parentFolderId already identifies the parent JCR node uniquely; the + // storage 'folderPath' argument is a RELATIVE sub-path resolved *from* that + // node (node.getNode(folderPath)), so it must stay empty. Passing the + // absolute node path made root-folder creation fail with + // "Folder with path : /Users/.../Private isn't found". AbstractNode folder = documentFileService.createFolder(getOwnerId(parentFolderId), parentFolderId, - parent.getPath(), + null, name, getCurrentUserIdentityId()); return toDocumentModel(folder); diff --git a/documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java b/documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java index ac8d1f211..fc9cf6b66 100644 --- a/documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java +++ b/documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java @@ -24,8 +24,8 @@ import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -307,7 +307,7 @@ public void listFolderChildrenBlankFolderFails() { public void getFolderBreadcrumb() throws Exception { when(documentFileService.getDocumentById(FOLDER_ID, USERNAME)).thenReturn(folderNode(FOLDER_ID)); when(documentFileService.getRootFolderOwnerId(FOLDER_ID)).thenReturn(OWNER_ID); - when(documentFileService.getBreadcrumb(eq(OWNER_ID), eq(FOLDER_ID), anyString(), eq(USER_IDENTITY_ID))) + when(documentFileService.getBreadcrumb(eq(OWNER_ID), eq(FOLDER_ID), isNull(), eq(USER_IDENTITY_ID))) .thenReturn(List.of(new BreadCrumbItem(FOLDER_ID, "Projects", "Projects", "/documents/Projects", false, null))); List models = documentMcpTool.getFolderBreadcrumb(FOLDER_ID); @@ -373,7 +373,7 @@ public void getDocumentsSizeDefaultsToCurrentUser() throws Exception { public void createFolder() throws Exception { when(documentFileService.getDocumentById(FOLDER_ID, USERNAME)).thenReturn(folderNode(FOLDER_ID)); when(documentFileService.getRootFolderOwnerId(FOLDER_ID)).thenReturn(OWNER_ID); - when(documentFileService.createFolder(eq(OWNER_ID), eq(FOLDER_ID), eq("/documents/Projects"), eq("New"), eq(USER_IDENTITY_ID))) + when(documentFileService.createFolder(eq(OWNER_ID), eq(FOLDER_ID), isNull(), eq("New"), eq(USER_IDENTITY_ID))) .thenReturn(folderNode("new-folder")); DocumentModel model = documentMcpTool.createFolder(FOLDER_ID, "New"); @@ -382,6 +382,31 @@ public void createFolder() throws Exception { assertEquals("new-folder", model.getId()); } + @Test + public void createFolderUnderRootFolderPassesNullFolderPath() throws Exception { + // Reproduces the live bug: creating a folder under the user's ROOT folder + // (e.g. "Private", whose absolute JCR path is /Users/.../root/Private). The + // parent folder id already identifies the JCR node, so the storage-level + // 'folderPath' must be null; passing the absolute path made the JCR storage + // throw ObjectNotFoundException "Folder with path ... isn't found". + FolderNode rootFolder = new FolderNode(); + rootFolder.setId(FOLDER_ID); + rootFolder.setName("Private"); + rootFolder.setPath("/Users/t___/te___/tes___/testuser1/Private"); + when(documentFileService.getDocumentById(FOLDER_ID, USERNAME)).thenReturn(rootFolder); + when(documentFileService.getRootFolderOwnerId(FOLDER_ID)).thenReturn(OWNER_ID); + when(documentFileService.createFolder(eq(OWNER_ID), eq(FOLDER_ID), isNull(), eq("MCPJam-Test"), eq(USER_IDENTITY_ID))) + .thenReturn(folderNode("new-folder")); + + DocumentModel model = documentMcpTool.createFolder(FOLDER_ID, "MCPJam-Test"); + + assertNotNull(model); + assertEquals("new-folder", model.getId()); + // folderPath must be null (not the absolute root path) so the storage resolves + // the parent purely from the folder id. + verify(documentFileService).createFolder(eq(OWNER_ID), eq(FOLDER_ID), isNull(), eq("MCPJam-Test"), eq(USER_IDENTITY_ID)); + } + @Test public void createFolderBlankNameFails() { assertThrows(IllegalArgumentException.class, () -> documentMcpTool.createFolder(FOLDER_ID, " ")); From ca10f7e884c0d5cb21794a08f1429f36541c3f82 Mon Sep 17 00:00:00 2001 From: Benjamin Mestrallet Date: Tue, 7 Jul 2026 10:58:36 +0100 Subject: [PATCH 06/15] feat: Add create_document/upload_document MCP tools using UploadToolUtils - EXO-88354 create_document turns chat-authored text/markdown/HTML into a real DMS file; upload_document imports a binary file from base64 or an SSRF-guarded URL fetch. Both stage the bytes via UploadToolUtils.materialize, wrap them in a one-entry zip (importFiles unzips), import into the target folder as the current user, and poll the folder to return the created document. Pins mcp-server-tools to the ai-contribution build that ships UploadToolUtils. Co-Authored-By: Claude Opus 4.8 (1M context) --- documents-services/pom.xml | 5 +- .../documents/mcp/DocumentMcpTool.java | 235 +++++++++++++++++- .../main/resources/ai-tool-definitions.json | 184 ++++++++++++-- .../documents/mcp/DocumentMcpToolTest.java | 84 ++++++- 4 files changed, 480 insertions(+), 28 deletions(-) diff --git a/documents-services/pom.xml b/documents-services/pom.xml index 18db90b6a..05eae8934 100644 --- a/documents-services/pom.xml +++ b/documents-services/pom.xml @@ -41,10 +41,13 @@ jar provided
- + io.meeds.mcp-server mcp-server-tools + 7.3.x-ai-contribution-SNAPSHOT provided diff --git a/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java b/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java index abc46cef2..75a179ec8 100644 --- a/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java +++ b/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java @@ -19,7 +19,12 @@ import static io.meeds.mcp.server.tool.util.McpToolPluginUtils.getInteger; import static io.meeds.mcp.server.util.McpToolUtils.formatDate; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; import org.apache.commons.lang3.StringUtils; import org.springframework.context.annotation.Profile; @@ -54,12 +59,14 @@ import org.exoplatform.social.core.profileproperty.ProfilePropertyService; import org.exoplatform.social.core.space.model.Space; import org.exoplatform.social.core.space.spi.SpaceService; +import org.exoplatform.upload.UploadService; import org.exoplatform.wiki.WikiException; import org.exoplatform.wiki.model.Page; import org.exoplatform.wiki.service.NoteService; import io.meeds.mcp.server.plugin.McpToolPlugin; import io.meeds.mcp.server.tool.model.UserModel; +import io.meeds.mcp.server.tool.util.UploadToolUtils; import io.meeds.mcp.server.tool.util.UserToolUtils; import io.meeds.social.translation.service.TranslationService; @@ -96,6 +103,8 @@ public class DocumentMcpTool implements McpToolPlugin { private final UserPortalConfigService portalConfigService; + private final UploadService uploadService; + public DocumentMcpTool(DocumentFileService documentFileService, AttachmentService attachmentService, IdentityManager identityManager, @@ -103,7 +112,8 @@ public DocumentMcpTool(DocumentFileService documentFileService, TranslationService translationService, ProfilePropertyService profilePropertyService, UserACL userAcl, - UserPortalConfigService portalConfigService) { + UserPortalConfigService portalConfigService, + UploadService uploadService) { this.documentFileService = documentFileService; this.attachmentService = attachmentService; this.identityManager = identityManager; @@ -112,6 +122,7 @@ public DocumentMcpTool(DocumentFileService documentFileService, this.profilePropertyService = profilePropertyService; this.userAcl = userAcl; this.portalConfigService = portalConfigService; + this.uploadService = uploadService; } public DocumentFolderModel getRootFolderBySpace(long spaceId) throws ObjectNotFoundException, IllegalAccessException { @@ -310,6 +321,79 @@ public DocumentModel createFolder(String parentFolderId, } } + /** + * Creates a text-based document (markdown, HTML, plain text, CSV, JSON…) from + * a chat-authored content string. This is the chat-native way to + * turn generated text into a real file in the Documents app. + * + * @param parentFolderId the folder where the document is created (get it from + * get_root_folder_for_user / get_root_folder_by_space / + * get_documents_by_folder_id) + * @param name the document file name (an extension is appended from the mime + * type when missing, e.g. 'notes' + text/markdown -> 'notes.md') + * @param content the text / markdown / HTML body + * @param mimeType optional mime type; defaults from the name extension + * @return the created document + */ + public DocumentModel createDocument(String parentFolderId, + String name, + String content, + String mimeType) throws IllegalAccessException, ObjectNotFoundException { + checkFolderIdParameter(parentFolderId); + if (StringUtils.isBlank(name)) { + throw new IllegalArgumentException("The 'name' parameter is mandatory to create a document (e.g. 'meeting-notes.md'). Ask the user for a document name."); + } + if (content == null) { + throw new IllegalArgumentException("The 'content' parameter is mandatory. Provide the text, markdown or HTML body of the document."); + } + String resolvedMime = resolveMimeType(name, mimeType); + String fileName = ensureExtension(name, resolvedMime); + return importContent(parentFolderId, fileName, content.getBytes(StandardCharsets.UTF_8)); + } + + /** + * Uploads a real / binary document (PDF, image, office file…) from exactly one + * of a base64 payload or an http(s) URL. The URL is fetched server-side behind + * an SSRF guard. + * + * @param parentFolderId the destination folder id (get it from + * get_root_folder_for_user / get_root_folder_by_space / + * get_documents_by_folder_id) + * @param name the document file name including its extension (e.g. + * 'report.pdf'); when uploading from a URL it may be left blank to + * reuse the name from the URL + * @param base64 the file bytes encoded in base64 (mutually exclusive with url) + * @param url an http(s) URL to download the file from (mutually exclusive with + * base64) + * @return the created document + */ + public DocumentModel uploadDocument(String parentFolderId, + String name, + String base64, + String url) throws IllegalAccessException, ObjectNotFoundException { + checkFolderIdParameter(parentFolderId); + boolean hasBase64 = StringUtils.isNotBlank(base64); + boolean hasUrl = StringUtils.isNotBlank(url); + if (hasBase64 == hasUrl) { + throw new IllegalArgumentException("Provide exactly one of 'base64' or 'url' to upload a document."); + } + byte[] bytes; + String fileName = name; + if (hasBase64) { + if (StringUtils.isBlank(name)) { + throw new IllegalArgumentException("The 'name' parameter is mandatory when uploading from base64; it becomes the document file name (e.g. 'report.pdf')."); + } + bytes = UploadToolUtils.decodeBase64(base64); + } else { + UploadToolUtils.FetchedImage fetched = UploadToolUtils.fetchUrl(url, UploadToolUtils.DEFAULT_MAX_BYTES, name); + bytes = fetched.bytes(); + if (StringUtils.isBlank(fileName)) { + fileName = fetched.fileName(); + } + } + return importContent(parentFolderId, fileName, bytes); + } + public void renameDocument(String documentId, String newName) throws IllegalAccessException, ObjectNotFoundException { checkDocumentIdParameter(documentId); if (StringUtils.isBlank(newName)) { @@ -555,6 +639,155 @@ private String fixNoteContentType(String contentType) { } } + // --------------------------------------------------------------------------- + // Content creation / upload helpers + // --------------------------------------------------------------------------- + + private static final int IMPORT_POLL_MAX_ATTEMPTS = 20; + + private static final long IMPORT_POLL_INTERVAL_MS = 500L; + + private static final int IMPORT_POLL_PAGE_SIZE = 200; + + /** + * Stages the given bytes as a single file and imports it into the folder, + * then resolves and returns the created document. {@code importFiles} expects + * a zip and unzips it, so the file is wrapped in a one-entry zip whose entry + * name becomes the created document title. + */ + private DocumentModel importContent(String parentFolderId, + String fileName, + byte[] bytes) throws IllegalAccessException, ObjectNotFoundException { + if (bytes == null || bytes.length == 0) { + throw new IllegalArgumentException("The document content is empty; there is nothing to create."); + } + // Validate the parent folder exists and is readable before staging anything, + // so a bad id fails with a clean ObjectNotFoundException / IllegalAccessException. + getNode(parentFolderId); + String entryName = sanitizeFileName(fileName); + String ownerId = String.valueOf(getOwnerId(parentFolderId)); + long userIdentityId = getCurrentUserIdentityId(); + String uploadId = null; + try { + uploadId = UploadToolUtils.materialize(uploadService, + zipSingleEntry(entryName, bytes), + entryName + ".zip", + "application/zip"); + // folderPath stays null: parentFolderId already identifies the JCR node + // (same rule as createFolder). conflict "rename" keeps existing documents + // untouched on a name clash. + documentFileService.importFiles(ownerId, + parentFolderId, + null, + uploadId, + "rename", + getCurrentUserAclIdentity(), + userIdentityId); + // On success the asynchronous import owns the upload resource and releases + // it once the file is created, so it must not be released here. + uploadId = null; + } catch (IllegalAccessException e) { + throw e; + } catch (Exception e) { + throw new IllegalStateException("Could not create the document '%s': %s".formatted(entryName, e.getMessage())); + } finally { + if (uploadId != null) { + UploadToolUtils.release(uploadService, uploadId); + } + } + return findImportedDocument(parentFolderId, entryName); + } + + /** + * The import runs on a background thread, so the created file is polled for in + * the destination folder until it appears (or a short timeout elapses). + */ + private DocumentModel findImportedDocument(String parentFolderId, + String fileName) throws ObjectNotFoundException, IllegalAccessException { + long userIdentityId = getCurrentUserIdentityId(); + DocumentFolderFilter filter = new DocumentFolderFilter(parentFolderId, null, null, null); + for (int attempt = 0; attempt < IMPORT_POLL_MAX_ATTEMPTS; attempt++) { + List children = documentFileService.getFolderChildNodes(filter, 0, IMPORT_POLL_PAGE_SIZE, userIdentityId); + DocumentFileModel match = children.stream() + .filter(FileNode.class::isInstance) + .map(FileNode.class::cast) + .filter(file -> StringUtils.equalsIgnoreCase(file.getName(), fileName)) + .reduce((first, second) -> second) + .map(this::toDocumentFileModel) + .orElse(null); + if (match != null) { + return match; + } + sleepQuietly(IMPORT_POLL_INTERVAL_MS); + } + throw new IllegalStateException(""" + The document '%s' was uploaded and is being created in the background. + Call list_folder_children on folder %s in a few seconds to retrieve it. + """.formatted(fileName, parentFolderId)); + } + + private static byte[] zipSingleEntry(String entryName, byte[] content) { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(output)) { + zip.putNextEntry(new ZipEntry(entryName)); + zip.write(content); + zip.closeEntry(); + } catch (IOException e) { + throw new IllegalStateException("Could not package the document content: " + e.getMessage()); + } + return output.toByteArray(); + } + + private static String sanitizeFileName(String fileName) { + String cleaned = StringUtils.trimToEmpty(fileName).replaceAll("[/\\\\]", "_"); + return cleaned.isEmpty() ? "document" : cleaned; + } + + private static String resolveMimeType(String name, String provided) { + if (StringUtils.isNotBlank(provided)) { + return provided; + } + String lower = StringUtils.lowerCase(name); + if (lower == null) { + return "text/plain"; + } else if (lower.endsWith(".md") || lower.endsWith(".markdown")) { + return "text/markdown"; + } else if (lower.endsWith(".html") || lower.endsWith(".htm")) { + return "text/html"; + } else if (lower.endsWith(".csv")) { + return "text/csv"; + } else if (lower.endsWith(".json")) { + return "application/json"; + } else if (lower.endsWith(".xml")) { + return "application/xml"; + } else { + return "text/plain"; + } + } + + private static String ensureExtension(String name, String mimeType) { + if (StringUtils.contains(name, '.')) { + return name; + } + String extension = switch (StringUtils.lowerCase(StringUtils.trimToEmpty(mimeType))) { + case "text/markdown" -> ".md"; + case "text/html" -> ".html"; + case "text/csv" -> ".csv"; + case "application/json" -> ".json"; + case "application/xml" -> ".xml"; + default -> ".txt"; + }; + return name + extension; + } + + private static void sleepQuietly(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + private long getCurrentUserIdentityId() { return identityManager.getOrCreateUserIdentity(getCurrentUserName()) .getIdentityId(); diff --git a/documents-services/src/main/resources/ai-tool-definitions.json b/documents-services/src/main/resources/ai-tool-definitions.json index 578340aa2..bf377a32c 100644 --- a/documents-services/src/main/resources/ai-tool-definitions.json +++ b/documents-services/src/main/resources/ai-tool-definitions.json @@ -12,7 +12,9 @@ "description": "space id for which the root folder is requested." } }, - "required": ["space_id"] + "required": [ + "space_id" + ] }, "annotations": { "readOnlyHint": true, @@ -46,7 +48,9 @@ "description": "Maximum number of results to return." } }, - "required": ["folder_id"] + "required": [ + "folder_id" + ] }, "annotations": { "readOnlyHint": true, @@ -82,7 +86,9 @@ "description": "The unique identifier of the document." } }, - "required": ["document_id"] + "required": [ + "document_id" + ] }, "annotations": { "readOnlyHint": true, @@ -103,7 +109,9 @@ "description": "The unique identifier of the document." } }, - "required": ["document_id"] + "required": [ + "document_id" + ] }, "annotations": { "readOnlyHint": true, @@ -119,11 +127,21 @@ "input_schema": { "type": "object", "properties": { - "document_id": { "type": "string" }, - "content_type": { "type": "string" }, - "content_id": { "type": "integer" } + "document_id": { + "type": "string" + }, + "content_type": { + "type": "string" + }, + "content_id": { + "type": "integer" + } }, - "required": ["document_id", "content_type", "content_id"] + "required": [ + "document_id", + "content_type", + "content_id" + ] }, "annotations": { "readOnlyHint": false, @@ -147,7 +165,10 @@ "type": "string" } }, - "required": ["document_id", "html_description"] + "required": [ + "document_id", + "html_description" + ] }, "annotations": { "readOnlyHint": false, @@ -168,7 +189,9 @@ "type": "string" } }, - "required": ["document_id"] + "required": [ + "document_id" + ] }, "annotations": { "readOnlyHint": true, @@ -209,7 +232,9 @@ "description": "If true, restricts results to favorite documents." } }, - "required": ["query"] + "required": [ + "query" + ] }, "annotations": { "readOnlyHint": true, @@ -238,7 +263,9 @@ "description": "Maximum number of results to return." } }, - "required": ["folder_id"] + "required": [ + "folder_id" + ] }, "annotations": { "readOnlyHint": true, @@ -259,7 +286,9 @@ "description": "The unique identifier of the folder." } }, - "required": ["folder_id"] + "required": [ + "folder_id" + ] }, "annotations": { "readOnlyHint": true, @@ -284,7 +313,9 @@ "description": "If true (default), include the child folders recursively." } }, - "required": ["folder_id"] + "required": [ + "folder_id" + ] }, "annotations": { "readOnlyHint": true, @@ -305,7 +336,9 @@ "description": "The unique identifier of the file." } }, - "required": ["document_id"] + "required": [ + "document_id" + ] }, "annotations": { "readOnlyHint": true, @@ -350,7 +383,48 @@ "description": "Name of the folder to create." } }, - "required": ["parent_folder_id", "name"] + "required": [ + "parent_folder_id", + "name" + ] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "require_approval": true + }, + { + "name": "create_document", + "title": "Create a text document in the DMS", + "description": "Creates a text-based document (markdown, HTML, plain text, CSV or JSON) from a content string authored in the chat. Use this to turn generated text into a real file in the Documents app. Call get_root_folder_for_user, get_root_folder_by_space or get_documents_by_folder_id first to get the parent_folder_id. For binary files (PDF, images, office files) use upload_document instead.", + "input_schema": { + "type": "object", + "properties": { + "parent_folder_id": { + "type": "string", + "description": "The unique identifier of the folder where the document is created." + }, + "name": { + "type": "string", + "description": "The document file name. If no extension is given, one is appended based on the mime type (e.g. 'notes' with text/markdown becomes 'notes.md')." + }, + "content": { + "type": "string", + "description": "The text, markdown or HTML body of the document." + }, + "mime_type": { + "type": "string", + "description": "Optional mime type (e.g. text/markdown, text/html, text/plain). Defaults from the name extension when omitted." + } + }, + "required": [ + "parent_folder_id", + "name", + "content" + ] }, "annotations": { "readOnlyHint": false, @@ -360,6 +434,42 @@ }, "require_approval": true }, + { + "name": "upload_document", + "title": "Upload a file to the DMS", + "description": "Uploads a real or binary document (PDF, image, office file, etc.) into a folder from exactly one of a base64 payload or an http(s) URL (fetched server-side behind an SSRF guard). Call get_root_folder_for_user, get_root_folder_by_space or get_documents_by_folder_id first to get the parent_folder_id. For text/markdown/HTML authored in the chat, use create_document instead.", + "input_schema": { + "type": "object", + "properties": { + "parent_folder_id": { + "type": "string", + "description": "The unique identifier of the destination folder." + }, + "name": { + "type": "string", + "description": "The document file name including its extension (e.g. 'report.pdf'). Mandatory with base64; optional with url (the name is then derived from the URL)." + }, + "base64": { + "type": "string", + "description": "The file bytes encoded in base64. Provide either base64 or url, not both." + }, + "url": { + "type": "string", + "description": "An http(s) URL to download the file from. Provide either base64 or url, not both." + } + }, + "required": [ + "parent_folder_id" + ] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "require_approval": true + }, { "name": "rename_document", "title": "Rename a DMS file or folder", @@ -376,7 +486,10 @@ "description": "The new name." } }, - "required": ["document_id", "new_name"] + "required": [ + "document_id", + "new_name" + ] }, "annotations": { "readOnlyHint": false, @@ -406,7 +519,10 @@ "description": "Behaviour when a document with the same name exists in the destination. Defaults to 'rename'." } }, - "required": ["document_id", "destination_folder_id"] + "required": [ + "document_id", + "destination_folder_id" + ] }, "annotations": { "readOnlyHint": false, @@ -432,7 +548,10 @@ "description": "The unique identifier of the destination folder." } }, - "required": ["document_id", "destination_folder_id"] + "required": [ + "document_id", + "destination_folder_id" + ] }, "annotations": { "readOnlyHint": false, @@ -458,7 +577,9 @@ "description": "Optional prefix added to the duplicated name (e.g. 'Copy of')." } }, - "required": ["document_id"] + "required": [ + "document_id" + ] }, "annotations": { "readOnlyHint": false, @@ -480,7 +601,9 @@ "description": "The unique identifier of the file or folder to delete." } }, - "required": ["document_id"] + "required": [ + "document_id" + ] }, "annotations": { "readOnlyHint": false, @@ -502,7 +625,9 @@ "description": "The unique identifier of the previously deleted document." } }, - "required": ["document_id"] + "required": [ + "document_id" + ] }, "annotations": { "readOnlyHint": false, @@ -524,7 +649,9 @@ "description": "The unique identifier of the version to restore." } }, - "required": ["version_id"] + "required": [ + "version_id" + ] }, "annotations": { "readOnlyHint": false, @@ -554,7 +681,11 @@ "description": "The new summary text." } }, - "required": ["document_id", "version_id", "summary"] + "required": [ + "document_id", + "version_id", + "summary" + ] }, "annotations": { "readOnlyHint": false, @@ -580,7 +711,10 @@ "description": "true to hide the document, false to make it visible." } }, - "required": ["document_id", "hidden"] + "required": [ + "document_id", + "hidden" + ] }, "annotations": { "readOnlyHint": false, diff --git a/documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java b/documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java index fc9cf6b66..b6d9031cd 100644 --- a/documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java +++ b/documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java @@ -62,6 +62,7 @@ import org.exoplatform.social.core.profileproperty.ProfilePropertyService; import org.exoplatform.social.core.space.model.Space; import org.exoplatform.social.core.space.spi.SpaceService; +import org.exoplatform.upload.UploadService; import io.meeds.social.translation.service.TranslationService; @@ -99,6 +100,7 @@ public void setUp() { ProfilePropertyService profilePropertyService = Mockito.mock(ProfilePropertyService.class); UserACL userAcl = Mockito.mock(UserACL.class); UserPortalConfigService portalConfigService = Mockito.mock(UserPortalConfigService.class); + UploadService uploadService = Mockito.mock(UploadService.class); currentIdentity = new Identity(USERNAME); org.exoplatform.social.core.identity.model.Identity socialIdentity = @@ -112,7 +114,8 @@ public void setUp() { translationService, profilePropertyService, userAcl, - portalConfigService) { + portalConfigService, + uploadService) { @Override public Identity getCurrentUserAclIdentity() { return currentIdentity; @@ -412,6 +415,85 @@ public void createFolderBlankNameFails() { assertThrows(IllegalArgumentException.class, () -> documentMcpTool.createFolder(FOLDER_ID, " ")); } + private FileNode fileNodeNamed(String id, String name) { + FileNode fileNode = new FileNode(); + fileNode.setId(id); + fileNode.setName(name); + fileNode.setPath("/documents/Projects/" + name); + fileNode.setMimeType("text/plain"); + fileNode.setSize(16L); + return fileNode; + } + + @Test + public void createDocumentAppendsExtensionAndImports() throws Exception { + when(documentFileService.getDocumentById(FOLDER_ID, USERNAME)).thenReturn(folderNode(FOLDER_ID)); + when(documentFileService.getRootFolderOwnerId(FOLDER_ID)).thenReturn(OWNER_ID); + // 'notes' has no extension: with text/markdown it becomes 'notes.md'. + when(documentFileService.getFolderChildNodes(any(), anyInt(), anyInt(), anyLong())) + .thenReturn(List.of(fileNodeNamed("doc-created", "notes.md"))); + + DocumentModel model = documentMcpTool.createDocument(FOLDER_ID, "notes", "# Hello", "text/markdown"); + + assertNotNull(model); + assertEquals("doc-created", model.getId()); + // folderPath must be null (parent resolved from the folder id) and conflict "rename". + verify(documentFileService).importFiles(eq(String.valueOf(OWNER_ID)), + eq(FOLDER_ID), + isNull(), + any(), + eq("rename"), + eq(currentIdentity), + eq(USER_IDENTITY_ID)); + } + + @Test + public void createDocumentBlankNameFails() { + assertThrows(IllegalArgumentException.class, () -> documentMcpTool.createDocument(FOLDER_ID, " ", "body", null)); + } + + @Test + public void createDocumentNullContentFails() { + assertThrows(IllegalArgumentException.class, () -> documentMcpTool.createDocument(FOLDER_ID, "notes.md", null, null)); + } + + @Test + public void uploadDocumentFromBase64() throws Exception { + when(documentFileService.getDocumentById(FOLDER_ID, USERNAME)).thenReturn(folderNode(FOLDER_ID)); + when(documentFileService.getRootFolderOwnerId(FOLDER_ID)).thenReturn(OWNER_ID); + when(documentFileService.getFolderChildNodes(any(), anyInt(), anyInt(), anyLong())) + .thenReturn(List.of(fileNodeNamed("upl-1", "hello.txt"))); + String base64 = java.util.Base64.getEncoder().encodeToString("hello".getBytes()); + + DocumentModel model = documentMcpTool.uploadDocument(FOLDER_ID, "hello.txt", base64, null); + + assertNotNull(model); + assertEquals("upl-1", model.getId()); + verify(documentFileService).importFiles(eq(String.valueOf(OWNER_ID)), + eq(FOLDER_ID), + isNull(), + any(), + eq("rename"), + eq(currentIdentity), + eq(USER_IDENTITY_ID)); + } + + @Test + public void uploadDocumentRequiresExactlyOneSource() { + String base64 = java.util.Base64.getEncoder().encodeToString("hello".getBytes()); + // neither base64 nor url + assertThrows(IllegalArgumentException.class, () -> documentMcpTool.uploadDocument(FOLDER_ID, "x.txt", null, null)); + // both base64 and url + assertThrows(IllegalArgumentException.class, + () -> documentMcpTool.uploadDocument(FOLDER_ID, "x.txt", base64, "https://example.com/x.txt")); + } + + @Test + public void uploadDocumentBase64RequiresName() { + String base64 = java.util.Base64.getEncoder().encodeToString("hello".getBytes()); + assertThrows(IllegalArgumentException.class, () -> documentMcpTool.uploadDocument(FOLDER_ID, " ", base64, null)); + } + @Test public void renameDocument() throws Exception { when(documentFileService.getRootFolderOwnerId(DOCUMENT_ID)).thenReturn(OWNER_ID); From 4c69e80d05c921bb2dfd982f5b61474b51209cc2 Mon Sep 17 00:00:00 2001 From: Benjamin Mestrallet Date: Tue, 7 Jul 2026 15:54:15 +0100 Subject: [PATCH 07/15] feat: upload_document accepts chat attachments + create_document honors mime_type - EXO-88354 upload_document now declares attachment_object_type/attachment_object_id so EVA forwards a chat-attached file/image; the bytes are resolved server-side as the current user (ACL enforced) via UploadToolUtils.resolveImage's attachment branch (no image-only constraint), keeping the existing base64/url paths. create_document forces the requested mime_type on the stored file via a new DocumentFileService/Storage updateDocumentMimeType (JCR jcr:content/jcr:mimeType), fixing .md files that importFiles left as application/octet-stream. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../service/DocumentFileService.java | 15 +++ .../storage/DocumentFileStorage.java | 6 ++ .../documents/mcp/DocumentMcpTool.java | 99 ++++++++++++++++--- .../service/DocumentFileServiceImpl.java | 7 ++ .../main/resources/ai-tool-definitions.json | 16 ++- .../documents/mcp/DocumentMcpToolTest.java | 67 ++++++++++++- .../storage/jcr/JCRDocumentFileStorage.java | 35 +++++++ 7 files changed, 225 insertions(+), 20 deletions(-) diff --git a/documents-api/src/main/java/org/exoplatform/documents/service/DocumentFileService.java b/documents-api/src/main/java/org/exoplatform/documents/service/DocumentFileService.java index 68fc34336..a1d1eab1b 100644 --- a/documents-api/src/main/java/org/exoplatform/documents/service/DocumentFileService.java +++ b/documents-api/src/main/java/org/exoplatform/documents/service/DocumentFileService.java @@ -331,6 +331,21 @@ default void updateDocumentDescription(long ownerId, throw new IllegalStateException("updateDocumentDescription method not implemented in the target class"); } + /** + * Overrides the stored mime type of a document. Used after an import, whose + * mime is derived from the file extension, to force a requested content type. + * + * @param documentId the document (file) UUID + * @param mimeType the mime type to store (e.g. text/markdown) + * @param aclIdentity the authenticated user identity id, whose edit permission + * is enforced + */ + default void updateDocumentMimeType(String documentId, + String mimeType, + long aclIdentity) throws IllegalStateException, IllegalAccessException, RepositoryException { + throw new IllegalStateException("updateDocumentMimeType method not implemented in the target class"); + } + /** * Updates the Text transcription of a Document * diff --git a/documents-api/src/main/java/org/exoplatform/documents/storage/DocumentFileStorage.java b/documents-api/src/main/java/org/exoplatform/documents/storage/DocumentFileStorage.java index d12f69769..63f3b2d70 100644 --- a/documents-api/src/main/java/org/exoplatform/documents/storage/DocumentFileStorage.java +++ b/documents-api/src/main/java/org/exoplatform/documents/storage/DocumentFileStorage.java @@ -276,6 +276,12 @@ default void updateDocumentDescription(long ownerId, throw new IllegalStateException("updateDocumentDescription not implemented in the target classs"); } + default void updateDocumentMimeType(String documentId, + String mimeType, + Identity aclIdentity) throws IllegalStateException, RepositoryException { + throw new IllegalStateException("updateDocumentMimeType not implemented in the target class"); + } + /** * Updates the Text transcription of a Document * diff --git a/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java b/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java index 75a179ec8..56a5130c3 100644 --- a/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java +++ b/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java @@ -32,6 +32,7 @@ import org.exoplatform.commons.ObjectAlreadyExistsException; import org.exoplatform.commons.exception.ObjectNotFoundException; +import org.exoplatform.commons.file.services.FileService; import org.exoplatform.container.ExoContainerContext; import org.exoplatform.documents.constant.FileListingType; import org.exoplatform.documents.mcp.model.BreadcrumbItemModel; @@ -91,6 +92,16 @@ public class DocumentMcpTool implements McpToolPlugin { private final AttachmentService attachmentService; + /** + * Social attachment service (distinct from the ecms {@link AttachmentService} + * above), used to resolve a chat-attached file referenced by + * attachment_object_type / attachment_object_id back + * to its stored bytes, as the current user, so its ACL is enforced. + */ + private final org.exoplatform.social.attachment.AttachmentService socialAttachmentService; + + private final FileService fileService; + private final IdentityManager identityManager; private final SpaceService spaceService; @@ -107,6 +118,8 @@ public class DocumentMcpTool implements McpToolPlugin { public DocumentMcpTool(DocumentFileService documentFileService, AttachmentService attachmentService, + org.exoplatform.social.attachment.AttachmentService socialAttachmentService, + FileService fileService, IdentityManager identityManager, SpaceService spaceService, TranslationService translationService, @@ -116,6 +129,8 @@ public DocumentMcpTool(DocumentFileService documentFileService, UploadService uploadService) { this.documentFileService = documentFileService; this.attachmentService = attachmentService; + this.socialAttachmentService = socialAttachmentService; + this.fileService = fileService; this.identityManager = identityManager; this.spaceService = spaceService; this.translationService = translationService; @@ -348,38 +363,81 @@ public DocumentModel createDocument(String parentFolderId, } String resolvedMime = resolveMimeType(name, mimeType); String fileName = ensureExtension(name, resolvedMime); - return importContent(parentFolderId, fileName, content.getBytes(StandardCharsets.UTF_8)); + DocumentModel document = importContent(parentFolderId, fileName, content.getBytes(StandardCharsets.UTF_8)); + // importFiles derives the stored mime type from the file extension, which + // yields application/octet-stream for extensions the platform does not + // register (e.g. .md). Force the requested/resolved mime so the file reports + // the right content type (get_document_content_by_id relies on it). + setMimeType(document.getId(), resolvedMime); + return getDocumentById(document.getId()); } /** * Uploads a real / binary document (PDF, image, office file…) from exactly one - * of a base64 payload or an http(s) URL. The URL is fetched server-side behind - * an SSRF guard. + * of three sources: a file/image the user attached in the chat conversation + * (resolved server-side as the current user via + * attachment_object_type / attachment_object_id), a + * base64 payload, or an http(s) URL (fetched server-side behind an SSRF + * guard). * * @param parentFolderId the destination folder id (get it from * get_root_folder_for_user / get_root_folder_by_space / * get_documents_by_folder_id) * @param name the document file name including its extension (e.g. - * 'report.pdf'); when uploading from a URL it may be left blank to - * reuse the name from the URL - * @param base64 the file bytes encoded in base64 (mutually exclusive with url) + * 'report.pdf'); when uploading from a URL or a chat attachment it may + * be left blank to reuse the source file name + * @param base64 the file bytes encoded in base64 (mutually exclusive with url / + * the chat attachment) * @param url an http(s) URL to download the file from (mutually exclusive with - * base64) + * base64 / the chat attachment) + * @param attachmentObjectType the object type of the file the user attached in + * chat; set automatically by the client — do not fill it from the + * model + * @param attachmentObjectId the object id of the file the user attached in + * chat; set automatically by the client — do not fill it from the + * model * @return the created document */ public DocumentModel uploadDocument(String parentFolderId, String name, String base64, - String url) throws IllegalAccessException, ObjectNotFoundException { + String url, + String attachmentObjectType, + String attachmentObjectId) throws IllegalAccessException, ObjectNotFoundException { checkFolderIdParameter(parentFolderId); + boolean hasAttachment = StringUtils.isNotBlank(attachmentObjectId); boolean hasBase64 = StringUtils.isNotBlank(base64); boolean hasUrl = StringUtils.isNotBlank(url); - if (hasBase64 == hasUrl) { - throw new IllegalArgumentException("Provide exactly one of 'base64' or 'url' to upload a document."); + int sources = (hasAttachment ? 1 : 0) + (hasBase64 ? 1 : 0) + (hasUrl ? 1 : 0); + if (sources == 0) { + throw new IllegalArgumentException(""" + Provide a file to upload from exactly one source: + a file/image the user attached in the conversation, a 'base64' payload, or an http(s) 'url'. + """); + } + if (sources > 1) { + throw new IllegalArgumentException("Provide the file to upload from only one source: the chat attachment, 'base64' or 'url' — not several."); } byte[] bytes; String fileName = name; - if (hasBase64) { + if (hasAttachment) { + // Reuse UploadToolUtils.resolveImage's attachment branch: it resolves the + // referenced file to its raw bytes as the current user (ACL enforced) and, + // unlike its base64/url branches, does NOT constrain to image mime types — + // so any attached file works. Pass null url/base64 to stay on that branch. + UploadToolUtils.FetchedImage fetched = UploadToolUtils.resolveImage(socialAttachmentService, + fileService, + getCurrentUserAclIdentity(), + null, + null, + attachmentObjectType, + attachmentObjectId, + UploadToolUtils.DEFAULT_MAX_BYTES); + bytes = fetched.bytes(); + if (StringUtils.isBlank(fileName)) { + fileName = fetched.fileName(); + } + } else if (hasBase64) { if (StringUtils.isBlank(name)) { throw new IllegalArgumentException("The 'name' parameter is mandatory when uploading from base64; it becomes the document file name (e.g. 'report.pdf')."); } @@ -698,6 +756,25 @@ private DocumentModel importContent(String parentFolderId, return findImportedDocument(parentFolderId, entryName); } + /** + * Forces the stored mime type of a freshly created document. The import + * pipeline derives the mime from the file extension, so text formats whose + * extension the platform does not register (e.g. .md) end up as + * application/octet-stream; this overrides it with the requested value. + */ + private void setMimeType(String documentId, String mimeType) { + if (StringUtils.isBlank(mimeType)) { + return; + } + try { + documentFileService.updateDocumentMimeType(documentId, mimeType, getCurrentUserIdentityId()); + } catch (Exception e) { + // The document was created successfully; only the mime override failed. + throw new IllegalStateException("The document was created but its content type could not be set to '%s': %s".formatted(mimeType, + e.getMessage())); + } + } + /** * The import runs on a background thread, so the created file is polled for in * the destination folder until it appears (or a short timeout elapses). diff --git a/documents-services/src/main/java/org/exoplatform/documents/service/DocumentFileServiceImpl.java b/documents-services/src/main/java/org/exoplatform/documents/service/DocumentFileServiceImpl.java index 3cb164ee3..4e98ab16e 100644 --- a/documents-services/src/main/java/org/exoplatform/documents/service/DocumentFileServiceImpl.java +++ b/documents-services/src/main/java/org/exoplatform/documents/service/DocumentFileServiceImpl.java @@ -490,6 +490,13 @@ public void updateDocumentDescription(long ownerId, documentFileStorage.updateDocumentDescription(ownerId, documentID, description, getAclUserIdentity(aclIdentity)); } + @Override + public void updateDocumentMimeType(String documentId, + String mimeType, + long aclIdentity) throws IllegalStateException, IllegalAccessException, RepositoryException { + documentFileStorage.updateDocumentMimeType(documentId, mimeType, getAclUserIdentity(aclIdentity)); + } + @Override public void updateAudioTranscription(String documentId, String transcription, long aclIdentity) throws IllegalAccessException { documentFileStorage.updateAudioTranscription(documentId, transcription, getAclUserIdentity(aclIdentity)); diff --git a/documents-services/src/main/resources/ai-tool-definitions.json b/documents-services/src/main/resources/ai-tool-definitions.json index bf377a32c..e7ea7af2a 100644 --- a/documents-services/src/main/resources/ai-tool-definitions.json +++ b/documents-services/src/main/resources/ai-tool-definitions.json @@ -437,7 +437,7 @@ { "name": "upload_document", "title": "Upload a file to the DMS", - "description": "Uploads a real or binary document (PDF, image, office file, etc.) into a folder from exactly one of a base64 payload or an http(s) URL (fetched server-side behind an SSRF guard). Call get_root_folder_for_user, get_root_folder_by_space or get_documents_by_folder_id first to get the parent_folder_id. For text/markdown/HTML authored in the chat, use create_document instead.", + "description": "Uploads a real or binary document (PDF, image, office file, etc.) into a folder. Use this when the user attaches a file or image in the conversation and asks to save it to the Documents app (the attachment is resolved automatically server-side), or from exactly one of a base64 payload or an http(s) URL (fetched server-side behind an SSRF guard). Call get_root_folder_for_user, get_root_folder_by_space or get_documents_by_folder_id first to get the parent_folder_id. For text/markdown/HTML authored in the chat, use create_document instead.", "input_schema": { "type": "object", "properties": { @@ -447,15 +447,23 @@ }, "name": { "type": "string", - "description": "The document file name including its extension (e.g. 'report.pdf'). Mandatory with base64; optional with url (the name is then derived from the URL)." + "description": "The document file name including its extension (e.g. 'report.pdf'). Mandatory with base64; optional with url or a chat attachment (the name is then derived from the source file)." }, "base64": { "type": "string", - "description": "The file bytes encoded in base64. Provide either base64 or url, not both." + "description": "The file bytes encoded in base64. Provide exactly one source: the chat attachment, base64 or url." }, "url": { "type": "string", - "description": "An http(s) URL to download the file from. Provide either base64 or url, not both." + "description": "An http(s) URL to download the file from. Provide exactly one source: the chat attachment, base64 or url." + }, + "attachment_object_type": { + "type": "string", + "description": "Internal: object type of an existing platform attachment to upload (set automatically by the client from the file/image the user attached in the conversation — do not fill this yourself). Read as the current user, so its ACL is enforced." + }, + "attachment_object_id": { + "type": "string", + "description": "Internal: object id of an existing platform attachment to upload (set automatically by the client — do not fill this yourself). Provide exactly one source: attachment_object_id, base64 or url." } }, "required": [ diff --git a/documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java b/documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java index b6d9031cd..5ce6fb085 100644 --- a/documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java +++ b/documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java @@ -82,6 +82,10 @@ public class DocumentMcpToolTest { private AttachmentService attachmentService; + private org.exoplatform.social.attachment.AttachmentService socialAttachmentService; + + private org.exoplatform.commons.file.services.FileService fileService; + private IdentityManager identityManager; private SpaceService spaceService; @@ -94,6 +98,8 @@ public class DocumentMcpToolTest { public void setUp() { documentFileService = Mockito.mock(DocumentFileService.class); attachmentService = Mockito.mock(AttachmentService.class); + socialAttachmentService = Mockito.mock(org.exoplatform.social.attachment.AttachmentService.class); + fileService = Mockito.mock(org.exoplatform.commons.file.services.FileService.class); identityManager = Mockito.mock(IdentityManager.class); spaceService = Mockito.mock(SpaceService.class); TranslationService translationService = Mockito.mock(TranslationService.class); @@ -109,6 +115,8 @@ public void setUp() { documentMcpTool = new DocumentMcpTool(documentFileService, attachmentService, + socialAttachmentService, + fileService, identityManager, spaceService, translationService, @@ -432,6 +440,8 @@ public void createDocumentAppendsExtensionAndImports() throws Exception { // 'notes' has no extension: with text/markdown it becomes 'notes.md'. when(documentFileService.getFolderChildNodes(any(), anyInt(), anyInt(), anyLong())) .thenReturn(List.of(fileNodeNamed("doc-created", "notes.md"))); + // After the mime override the tool re-reads the created document by id. + when(documentFileService.getDocumentById("doc-created", USERNAME)).thenReturn(fileNodeNamed("doc-created", "notes.md")); DocumentModel model = documentMcpTool.createDocument(FOLDER_ID, "notes", "# Hello", "text/markdown"); @@ -445,6 +455,9 @@ public void createDocumentAppendsExtensionAndImports() throws Exception { eq("rename"), eq(currentIdentity), eq(USER_IDENTITY_ID)); + // The requested mime type is forced on the stored file (importFiles would + // otherwise leave .md as application/octet-stream). + verify(documentFileService).updateDocumentMimeType(eq("doc-created"), eq("text/markdown"), eq(USER_IDENTITY_ID)); } @Test @@ -465,7 +478,7 @@ public void uploadDocumentFromBase64() throws Exception { .thenReturn(List.of(fileNodeNamed("upl-1", "hello.txt"))); String base64 = java.util.Base64.getEncoder().encodeToString("hello".getBytes()); - DocumentModel model = documentMcpTool.uploadDocument(FOLDER_ID, "hello.txt", base64, null); + DocumentModel model = documentMcpTool.uploadDocument(FOLDER_ID, "hello.txt", base64, null, null, null); assertNotNull(model); assertEquals("upl-1", model.getId()); @@ -478,20 +491,64 @@ public void uploadDocumentFromBase64() throws Exception { eq(USER_IDENTITY_ID)); } + @Test + public void uploadDocumentFromChatAttachment() throws Exception { + when(documentFileService.getDocumentById(FOLDER_ID, USERNAME)).thenReturn(folderNode(FOLDER_ID)); + when(documentFileService.getRootFolderOwnerId(FOLDER_ID)).thenReturn(OWNER_ID); + when(documentFileService.getFolderChildNodes(any(), anyInt(), anyInt(), anyLong())) + .thenReturn(List.of(fileNodeNamed("upl-att", "screenshot.png"))); + // The client injects a platform attachment reference; the tool resolves its + // bytes as the current user (ACL enforced) via the social AttachmentService. + when(socialAttachmentService.getAttachmentFileIds(eq("activity"), eq("77"), eq(currentIdentity))) + .thenReturn(List.of("999")); + org.exoplatform.commons.file.model.FileItem fileItem = Mockito.mock(org.exoplatform.commons.file.model.FileItem.class); + org.exoplatform.commons.file.model.FileInfo fileInfo = Mockito.mock(org.exoplatform.commons.file.model.FileInfo.class); + when(fileItem.getAsByte()).thenReturn("PNGBYTES".getBytes()); + when(fileItem.getFileInfo()).thenReturn(fileInfo); + when(fileInfo.getMimetype()).thenReturn("image/png"); + when(fileInfo.getName()).thenReturn("screenshot.png"); + when(fileService.getFile(999L)).thenReturn(fileItem); + + // name left blank -> reused from the resolved attachment file name. + DocumentModel model = documentMcpTool.uploadDocument(FOLDER_ID, null, null, null, "activity", "77"); + + assertNotNull(model); + assertEquals("upl-att", model.getId()); + verify(documentFileService).importFiles(eq(String.valueOf(OWNER_ID)), + eq(FOLDER_ID), + isNull(), + any(), + eq("rename"), + eq(currentIdentity), + eq(USER_IDENTITY_ID)); + } + + @Test + public void uploadDocumentUnknownAttachmentFails() throws Exception { + when(socialAttachmentService.getAttachmentFileIds(eq("activity"), eq("77"), eq(currentIdentity))) + .thenReturn(java.util.Collections.emptyList()); + + assertThrows(ObjectNotFoundException.class, + () -> documentMcpTool.uploadDocument(FOLDER_ID, null, null, null, "activity", "77")); + } + @Test public void uploadDocumentRequiresExactlyOneSource() { String base64 = java.util.Base64.getEncoder().encodeToString("hello".getBytes()); - // neither base64 nor url - assertThrows(IllegalArgumentException.class, () -> documentMcpTool.uploadDocument(FOLDER_ID, "x.txt", null, null)); + // no source at all + assertThrows(IllegalArgumentException.class, () -> documentMcpTool.uploadDocument(FOLDER_ID, "x.txt", null, null, null, null)); // both base64 and url assertThrows(IllegalArgumentException.class, - () -> documentMcpTool.uploadDocument(FOLDER_ID, "x.txt", base64, "https://example.com/x.txt")); + () -> documentMcpTool.uploadDocument(FOLDER_ID, "x.txt", base64, "https://example.com/x.txt", null, null)); + // both an attachment and base64 + assertThrows(IllegalArgumentException.class, + () -> documentMcpTool.uploadDocument(FOLDER_ID, "x.txt", base64, null, "activity", "77")); } @Test public void uploadDocumentBase64RequiresName() { String base64 = java.util.Base64.getEncoder().encodeToString("hello".getBytes()); - assertThrows(IllegalArgumentException.class, () -> documentMcpTool.uploadDocument(FOLDER_ID, " ", base64, null)); + assertThrows(IllegalArgumentException.class, () -> documentMcpTool.uploadDocument(FOLDER_ID, " ", base64, null, null, null)); } @Test diff --git a/documents-storage-jcr/src/main/java/org/exoplatform/documents/storage/jcr/JCRDocumentFileStorage.java b/documents-storage-jcr/src/main/java/org/exoplatform/documents/storage/jcr/JCRDocumentFileStorage.java index 484e15957..0e2bb732e 100644 --- a/documents-storage-jcr/src/main/java/org/exoplatform/documents/storage/jcr/JCRDocumentFileStorage.java +++ b/documents-storage-jcr/src/main/java/org/exoplatform/documents/storage/jcr/JCRDocumentFileStorage.java @@ -2134,6 +2134,41 @@ public void updateAudioTranscription(String documentId, } } + @Override + public void updateDocumentMimeType(String documentId, + String mimeType, + Identity aclIdentity) throws IllegalStateException, RepositoryException { + String path = null; + SessionProvider sessionProvider = null; + try { // NOSONAR + sessionProvider = getUserSessionProvider(repositoryService, aclIdentity); + Session session = sessionProvider.getSession(COLLABORATION, repositoryService.getCurrentRepository()); + Node node = getNodeByIdentifier(session, documentId); + path = node == null ? null : node.getPath(); + if (node == null || !JCRDocumentsUtil.hasEditPermission(session, node)) { + throw new AccessDeniedException("User doesn't have edit permission on file '%s'".formatted(path == null ? documentId + : path)); + } + // The binary + its mime type live on the nt:resource child node + // (jcr:content / jcr:mimeType), not on the nt:file node itself. + if (node.hasNode(NodeTypeConstants.JCR_CONTENT)) { + Node content = node.getNode(NodeTypeConstants.JCR_CONTENT); + content.setProperty(NodeTypeConstants.JCR_MIME_TYPE, mimeType); + session.save(); + } + } catch (AccessDeniedException e) { + throw e; + } catch (RepositoryException e) { + throw e; + } catch (Exception e) { + throw new IllegalStateException("Error updating mime type of document '%s'".formatted(path == null ? documentId : path), e); + } finally { + if (sessionProvider != null) { + sessionProvider.close(); + } + } + } + @Override public void updateAudioTranscription(String documentId, String transcription) { String path = null; From 5818f5aed73be00229fd682d801da9fb8f74dc2f Mon Sep 17 00:00:00 2001 From: Benjamin Mestrallet Date: Tue, 7 Jul 2026 17:46:18 +0100 Subject: [PATCH 08/15] revert: drop JCR mime-forcing in create_document; content-type follows file extension - EXO-88354 The prior commit forced the requested mime_type onto the stored file via a new DocumentFileService/Storage updateDocumentMimeType that opened a JCR session and set jcr:content/jcr:mimeType. Revert that JCR plumbing entirely: create_document's stored content type now follows the file extension (resolved by the platform MimeTypeResolver), the pre-fix behavior. The mime_type param is kept but is advisory (extension inference only). The upload_document chat-attachment support is untouched. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../service/DocumentFileService.java | 15 -------- .../storage/DocumentFileStorage.java | 6 ---- .../documents/mcp/DocumentMcpTool.java | 36 +++++-------------- .../service/DocumentFileServiceImpl.java | 7 ---- .../main/resources/ai-tool-definitions.json | 4 +-- .../documents/mcp/DocumentMcpToolTest.java | 5 --- .../storage/jcr/JCRDocumentFileStorage.java | 35 ------------------ 7 files changed, 11 insertions(+), 97 deletions(-) diff --git a/documents-api/src/main/java/org/exoplatform/documents/service/DocumentFileService.java b/documents-api/src/main/java/org/exoplatform/documents/service/DocumentFileService.java index a1d1eab1b..68fc34336 100644 --- a/documents-api/src/main/java/org/exoplatform/documents/service/DocumentFileService.java +++ b/documents-api/src/main/java/org/exoplatform/documents/service/DocumentFileService.java @@ -331,21 +331,6 @@ default void updateDocumentDescription(long ownerId, throw new IllegalStateException("updateDocumentDescription method not implemented in the target class"); } - /** - * Overrides the stored mime type of a document. Used after an import, whose - * mime is derived from the file extension, to force a requested content type. - * - * @param documentId the document (file) UUID - * @param mimeType the mime type to store (e.g. text/markdown) - * @param aclIdentity the authenticated user identity id, whose edit permission - * is enforced - */ - default void updateDocumentMimeType(String documentId, - String mimeType, - long aclIdentity) throws IllegalStateException, IllegalAccessException, RepositoryException { - throw new IllegalStateException("updateDocumentMimeType method not implemented in the target class"); - } - /** * Updates the Text transcription of a Document * diff --git a/documents-api/src/main/java/org/exoplatform/documents/storage/DocumentFileStorage.java b/documents-api/src/main/java/org/exoplatform/documents/storage/DocumentFileStorage.java index 63f3b2d70..d12f69769 100644 --- a/documents-api/src/main/java/org/exoplatform/documents/storage/DocumentFileStorage.java +++ b/documents-api/src/main/java/org/exoplatform/documents/storage/DocumentFileStorage.java @@ -276,12 +276,6 @@ default void updateDocumentDescription(long ownerId, throw new IllegalStateException("updateDocumentDescription not implemented in the target classs"); } - default void updateDocumentMimeType(String documentId, - String mimeType, - Identity aclIdentity) throws IllegalStateException, RepositoryException { - throw new IllegalStateException("updateDocumentMimeType not implemented in the target class"); - } - /** * Updates the Text transcription of a Document * diff --git a/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java b/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java index 56a5130c3..36f27b98f 100644 --- a/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java +++ b/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java @@ -347,7 +347,14 @@ public DocumentModel createFolder(String parentFolderId, * @param name the document file name (an extension is appended from the mime * type when missing, e.g. 'notes' + text/markdown -> 'notes.md') * @param content the text / markdown / HTML body - * @param mimeType optional mime type; defaults from the name extension + * @param mimeType optional mime type; only used to infer a file extension when + * the name has none (advisory). The stored content type is derived + * from the file extension by the platform MimeTypeResolver: use a + * registered extension (.txt, .html, .docx, .pdf, image types…) so the + * file has the correct type and is readable via + * get_document_content_by_id; an unregistered extension like .md is + * stored as application/octet-stream and its text won't be + * extractable. * @return the created document */ public DocumentModel createDocument(String parentFolderId, @@ -363,13 +370,7 @@ public DocumentModel createDocument(String parentFolderId, } String resolvedMime = resolveMimeType(name, mimeType); String fileName = ensureExtension(name, resolvedMime); - DocumentModel document = importContent(parentFolderId, fileName, content.getBytes(StandardCharsets.UTF_8)); - // importFiles derives the stored mime type from the file extension, which - // yields application/octet-stream for extensions the platform does not - // register (e.g. .md). Force the requested/resolved mime so the file reports - // the right content type (get_document_content_by_id relies on it). - setMimeType(document.getId(), resolvedMime); - return getDocumentById(document.getId()); + return importContent(parentFolderId, fileName, content.getBytes(StandardCharsets.UTF_8)); } /** @@ -756,25 +757,6 @@ private DocumentModel importContent(String parentFolderId, return findImportedDocument(parentFolderId, entryName); } - /** - * Forces the stored mime type of a freshly created document. The import - * pipeline derives the mime from the file extension, so text formats whose - * extension the platform does not register (e.g. .md) end up as - * application/octet-stream; this overrides it with the requested value. - */ - private void setMimeType(String documentId, String mimeType) { - if (StringUtils.isBlank(mimeType)) { - return; - } - try { - documentFileService.updateDocumentMimeType(documentId, mimeType, getCurrentUserIdentityId()); - } catch (Exception e) { - // The document was created successfully; only the mime override failed. - throw new IllegalStateException("The document was created but its content type could not be set to '%s': %s".formatted(mimeType, - e.getMessage())); - } - } - /** * The import runs on a background thread, so the created file is polled for in * the destination folder until it appears (or a short timeout elapses). diff --git a/documents-services/src/main/java/org/exoplatform/documents/service/DocumentFileServiceImpl.java b/documents-services/src/main/java/org/exoplatform/documents/service/DocumentFileServiceImpl.java index 4e98ab16e..3cb164ee3 100644 --- a/documents-services/src/main/java/org/exoplatform/documents/service/DocumentFileServiceImpl.java +++ b/documents-services/src/main/java/org/exoplatform/documents/service/DocumentFileServiceImpl.java @@ -490,13 +490,6 @@ public void updateDocumentDescription(long ownerId, documentFileStorage.updateDocumentDescription(ownerId, documentID, description, getAclUserIdentity(aclIdentity)); } - @Override - public void updateDocumentMimeType(String documentId, - String mimeType, - long aclIdentity) throws IllegalStateException, IllegalAccessException, RepositoryException { - documentFileStorage.updateDocumentMimeType(documentId, mimeType, getAclUserIdentity(aclIdentity)); - } - @Override public void updateAudioTranscription(String documentId, String transcription, long aclIdentity) throws IllegalAccessException { documentFileStorage.updateAudioTranscription(documentId, transcription, getAclUserIdentity(aclIdentity)); diff --git a/documents-services/src/main/resources/ai-tool-definitions.json b/documents-services/src/main/resources/ai-tool-definitions.json index e7ea7af2a..c3d2bd0cf 100644 --- a/documents-services/src/main/resources/ai-tool-definitions.json +++ b/documents-services/src/main/resources/ai-tool-definitions.json @@ -399,7 +399,7 @@ { "name": "create_document", "title": "Create a text document in the DMS", - "description": "Creates a text-based document (markdown, HTML, plain text, CSV or JSON) from a content string authored in the chat. Use this to turn generated text into a real file in the Documents app. Call get_root_folder_for_user, get_root_folder_by_space or get_documents_by_folder_id first to get the parent_folder_id. For binary files (PDF, images, office files) use upload_document instead.", + "description": "Creates a text-based document (markdown, HTML, plain text, CSV or JSON) from a content string authored in the chat. Use this to turn generated text into a real file in the Documents app. Call get_root_folder_for_user, get_root_folder_by_space or get_documents_by_folder_id first to get the parent_folder_id. The stored content type is derived from the file extension by the platform: use a registered extension (.txt, .html, .docx, .pdf, image types, etc.) so the file has the correct type and is readable via get_document_content_by_id; an unregistered extension like .md is stored as application/octet-stream and its text won't be extractable. For binary files (PDF, images, office files) use upload_document instead.", "input_schema": { "type": "object", "properties": { @@ -417,7 +417,7 @@ }, "mime_type": { "type": "string", - "description": "Optional mime type (e.g. text/markdown, text/html, text/plain). Defaults from the name extension when omitted." + "description": "Optional, advisory. Only used to infer a file extension when the name has none; the stored content type is derived from the file extension by the platform, not from this value." } }, "required": [ diff --git a/documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java b/documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java index 5ce6fb085..a48b1707b 100644 --- a/documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java +++ b/documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java @@ -440,8 +440,6 @@ public void createDocumentAppendsExtensionAndImports() throws Exception { // 'notes' has no extension: with text/markdown it becomes 'notes.md'. when(documentFileService.getFolderChildNodes(any(), anyInt(), anyInt(), anyLong())) .thenReturn(List.of(fileNodeNamed("doc-created", "notes.md"))); - // After the mime override the tool re-reads the created document by id. - when(documentFileService.getDocumentById("doc-created", USERNAME)).thenReturn(fileNodeNamed("doc-created", "notes.md")); DocumentModel model = documentMcpTool.createDocument(FOLDER_ID, "notes", "# Hello", "text/markdown"); @@ -455,9 +453,6 @@ public void createDocumentAppendsExtensionAndImports() throws Exception { eq("rename"), eq(currentIdentity), eq(USER_IDENTITY_ID)); - // The requested mime type is forced on the stored file (importFiles would - // otherwise leave .md as application/octet-stream). - verify(documentFileService).updateDocumentMimeType(eq("doc-created"), eq("text/markdown"), eq(USER_IDENTITY_ID)); } @Test diff --git a/documents-storage-jcr/src/main/java/org/exoplatform/documents/storage/jcr/JCRDocumentFileStorage.java b/documents-storage-jcr/src/main/java/org/exoplatform/documents/storage/jcr/JCRDocumentFileStorage.java index 0e2bb732e..484e15957 100644 --- a/documents-storage-jcr/src/main/java/org/exoplatform/documents/storage/jcr/JCRDocumentFileStorage.java +++ b/documents-storage-jcr/src/main/java/org/exoplatform/documents/storage/jcr/JCRDocumentFileStorage.java @@ -2134,41 +2134,6 @@ public void updateAudioTranscription(String documentId, } } - @Override - public void updateDocumentMimeType(String documentId, - String mimeType, - Identity aclIdentity) throws IllegalStateException, RepositoryException { - String path = null; - SessionProvider sessionProvider = null; - try { // NOSONAR - sessionProvider = getUserSessionProvider(repositoryService, aclIdentity); - Session session = sessionProvider.getSession(COLLABORATION, repositoryService.getCurrentRepository()); - Node node = getNodeByIdentifier(session, documentId); - path = node == null ? null : node.getPath(); - if (node == null || !JCRDocumentsUtil.hasEditPermission(session, node)) { - throw new AccessDeniedException("User doesn't have edit permission on file '%s'".formatted(path == null ? documentId - : path)); - } - // The binary + its mime type live on the nt:resource child node - // (jcr:content / jcr:mimeType), not on the nt:file node itself. - if (node.hasNode(NodeTypeConstants.JCR_CONTENT)) { - Node content = node.getNode(NodeTypeConstants.JCR_CONTENT); - content.setProperty(NodeTypeConstants.JCR_MIME_TYPE, mimeType); - session.save(); - } - } catch (AccessDeniedException e) { - throw e; - } catch (RepositoryException e) { - throw e; - } catch (Exception e) { - throw new IllegalStateException("Error updating mime type of document '%s'".formatted(path == null ? documentId : path), e); - } finally { - if (sessionProvider != null) { - sessionProvider.close(); - } - } - } - @Override public void updateAudioTranscription(String documentId, String transcription) { String path = null; From d50cb7775517ae7be573767fc7ff213595851b34 Mon Sep 17 00:00:00 2001 From: Benjamin Mestrallet Date: Tue, 7 Jul 2026 18:05:05 +0100 Subject: [PATCH 09/15] refactor: use renamed UploadToolUtils.FetchedContent - EXO-88354 Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 97 +++++++++++++++++++ .../documents/mcp/DocumentMcpTool.java | 4 +- 2 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..fa2cda69b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,97 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +eXo Documents is an add-on for the eXo Platform: the documents/files management feature +(drives, file timeline/folder browsing, trash, public access links, attachments, previews, +WebDAV access, search and notifications). It is a multi-module Maven project whose backend +runs inside the eXo Platform Kernel (an IoC container configured via XML) and whose frontend +is a set of Vue 2 portlet apps bundled with Webpack. + +## Build & test + +JDK 21 and Maven. The project inherits from `org.exoplatform:maven-exo-parent-pom` and depends +on `org.exoplatform.jcr:jcr-parent` (7.2.x-SNAPSHOT). Most dependencies resolve from +`https://repository.exoplatform.org/public`, so a network/Nexus connection is required. + +```bash +# Full build (compiles Java, runs the frontend build via frontend-maven-plugin, runs tests) +mvn clean install + +# Build skipping tests +mvn clean install -DskipTests + +# CI build profiles (see .github/workflows/prbuild.yml) +mvn clean install -Pexo-release,coverage + +# Run the test suite for one module +mvn test -pl documents-services + +# Run a single test class / single method +mvn test -pl documents-services -Dtest=DocumentFileServiceTest +mvn test -pl documents-services -Dtest=DocumentFileServiceTest#methodName +``` + +Frontend (run inside `documents-webapp/`; also wired into the Maven build via frontend-maven-plugin): + +```bash +npm run build # production webpack build -> target/documents-portlet/ +npm run watch # webpack --watch +npm run lint # eslint --fix over src/main/webapp/vue-app +npm run eslint-check # eslint without fixing (CI check) +``` + +## Module architecture + +The Maven reactor builds these modules in order; the dependency direction is API ← storage/services ← webapp: + +- **documents-api** — pure contracts: service interfaces (`service/`), storage SPI interfaces + (`storage/`), and the domain model POJOs (`model/`) + constants. No implementations here. + Both other backend modules depend on this. +- **documents-storage-jcr** — implements the storage SPI against eXo JCR (Java Content Repository). + This is where documents physically live as JCR nodes. Contains bulk actions, JCR search, + WebDAV node handling, and the legacy search layer. +- **documents-services** — implements the service interfaces, REST endpoints (`rest/`), DAOs/entities + (RDBMS via JPA, e.g. for public-access links), event listeners (`listener/`), notification + plugins/providers (`notification/`), filters, and websocket service. +- **documents-webapp** — the portlet WAR: Vue frontend (`src/main/webapp/vue-app/`) plus the eXo + Kernel/portlet wiring under `WEB-INF/conf` and `WEB-INF/*.xml`. +- **documents-webdav-webapp** — separate WAR exposing documents over WebDAV. +- **documents-packaging** — assembles the deployable add-on zip. + +### Backend wiring (important) + +There is no Spring/CDI. Components are registered with the **eXo Kernel container** through XML in +`documents-webapp/src/main/webapp/WEB-INF/conf/documents/`. To add or change a backend service, +listener, REST resource, search connector, or filter, you must edit the corresponding +`*-configuration.xml` (chiefly `documents-service-configuration.xml`) — registering the class in +code alone does nothing. The pattern is `` (key = interface, type = impl) and +`` (attaching plugins/listeners to a target component such as +`ListenerService`, `SpaceService`, `SearchService`, `ExtensibleFilter`). + +The layering at runtime: REST (`DocumentFileRest`) → Service impl (`DocumentFileServiceImpl`, +`PublicDocumentAccessServiceImpl`, `ExternalDownloadServiceImpl`) → Storage SPI +(`DocumentFileStorage`, `TrashStorage`, `JCRDeleteFileStorage`, `PublicDocumentAccessStorage`) → +JCR / JPA implementations in documents-storage-jcr and documents-services. + +### Frontend architecture + +Each subdirectory of `documents-webapp/src/main/webapp/vue-app/` is an independent Vue 2 app with a +`main.js` entry, registered as a separate Webpack bundle in `webpack.prod.js` (see the `entry` map — +e.g. `documents`, `attachment`, `trash-management`, `files-search`, `restrictedDrive`, the offline +and pwa extensions). Bundles output to `target/documents-portlet/js/[name].bundle.js` with +`libraryTarget: 'amd'`. `vue`, `vuetify`, and `jquery` are **externals** provided by the host +platform, not bundled. ESLint uses `eslint-config-meedsio` plus the Vue and vuejs-accessibility +plugins. + +## Conventions + +- Backend tests use JUnit/Mockito and live in `src/test/java` mirroring the package of the class + under test. They run inside a mocked Kernel context, not a live container. +- i18n: resource bundles are managed through Crowdin (`crowdin.yml`, `translations.properties`, and + the `*-crowdin*.yml` workflows). Edit the source English bundle; translations sync via CI. +- Version is `7.2.x-SNAPSHOT`; the default branch is `develop` and PRs target it. +- JIRA issue keys (e.g. `EXO-XXXXX`) are referenced in commit/PR titles following the + `type: summary - EXO-XXXXX` convention seen in git history. diff --git a/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java b/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java index 36f27b98f..992b847bb 100644 --- a/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java +++ b/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java @@ -426,7 +426,7 @@ public DocumentModel uploadDocument(String parentFolderId, // referenced file to its raw bytes as the current user (ACL enforced) and, // unlike its base64/url branches, does NOT constrain to image mime types — // so any attached file works. Pass null url/base64 to stay on that branch. - UploadToolUtils.FetchedImage fetched = UploadToolUtils.resolveImage(socialAttachmentService, + UploadToolUtils.FetchedContent fetched = UploadToolUtils.resolveImage(socialAttachmentService, fileService, getCurrentUserAclIdentity(), null, @@ -444,7 +444,7 @@ public DocumentModel uploadDocument(String parentFolderId, } bytes = UploadToolUtils.decodeBase64(base64); } else { - UploadToolUtils.FetchedImage fetched = UploadToolUtils.fetchUrl(url, UploadToolUtils.DEFAULT_MAX_BYTES, name); + UploadToolUtils.FetchedContent fetched = UploadToolUtils.fetchUrl(url, UploadToolUtils.DEFAULT_MAX_BYTES, name); bytes = fetched.bytes(); if (StringUtils.isBlank(fileName)) { fileName = fetched.fileName(); From c19160e5cf63de40d2302338dc7cd8848490c6f4 Mon Sep 17 00:00:00 2001 From: Benjamin Mestrallet Date: Tue, 7 Jul 2026 18:29:29 +0100 Subject: [PATCH 10/15] feat: clear error messages for create_document/upload_document bad input - EXO-88354 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../documents/mcp/DocumentMcpTool.java | 87 ++++++++++++------- .../main/resources/ai-tool-definitions.json | 2 +- .../documents/mcp/DocumentMcpToolTest.java | 46 +++++++++- 3 files changed, 103 insertions(+), 32 deletions(-) diff --git a/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java b/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java index 992b847bb..de089016e 100644 --- a/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java +++ b/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java @@ -23,6 +23,7 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.List; +import java.util.Set; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; @@ -88,6 +89,17 @@ public class DocumentMcpTool implements McpToolPlugin { public static final String FILE_URL_FORMAT = "/portal/dw/documents?documentPreviewId=%s"; + /** + * Structured / binary office and PDF formats that cannot be produced from a + * plain text string: they are zipped packages (OOXML/ODF) or a binary layout + * (PDF), so writing text content as the file body would yield a corrupt file. + * {@link #createDocument} rejects these up front and points to + * {@code upload_document} (real file bytes) instead. + */ + private static final Set UNSUPPORTED_BINARY_EXTENSIONS = + Set.of("docx", "xlsx", "pptx", "ppsx", "potx", "dotx", "xltx", + "xls", "doc", "ppt", "odt", "ods", "odp", "pdf"); + private final DocumentFileService documentFileService; private final AttachmentService attachmentService; @@ -365,8 +377,15 @@ public DocumentModel createDocument(String parentFolderId, if (StringUtils.isBlank(name)) { throw new IllegalArgumentException("The 'name' parameter is mandatory to create a document (e.g. 'meeting-notes.md'). Ask the user for a document name."); } - if (content == null) { - throw new IllegalArgumentException("The 'content' parameter is mandatory. Provide the text, markdown or HTML body of the document."); + String extension = StringUtils.substringAfterLast(StringUtils.lowerCase(StringUtils.trimToEmpty(name)), "."); + if (UNSUPPORTED_BINARY_EXTENSIONS.contains(extension)) { + throw new IllegalArgumentException(("create_document writes text content as the file body, so it cannot produce a valid" + + " '%s' file — office and PDF formats are structured packages, not text. Creating an empty office document from a" + + " template isn't supported yet. For text use .txt/.md/.html, or use upload_document with the real file bytes" + + " (base64, url, or a file attached in the conversation).").formatted(extension)); + } + if (StringUtils.isBlank(content)) { + throw new IllegalArgumentException("content is required to create a document (the text to write into the file)."); } String resolvedMime = resolveMimeType(name, mimeType); String fileName = ensureExtension(name, resolvedMime); @@ -421,34 +440,44 @@ public DocumentModel uploadDocument(String parentFolderId, } byte[] bytes; String fileName = name; - if (hasAttachment) { - // Reuse UploadToolUtils.resolveImage's attachment branch: it resolves the - // referenced file to its raw bytes as the current user (ACL enforced) and, - // unlike its base64/url branches, does NOT constrain to image mime types — - // so any attached file works. Pass null url/base64 to stay on that branch. - UploadToolUtils.FetchedContent fetched = UploadToolUtils.resolveImage(socialAttachmentService, - fileService, - getCurrentUserAclIdentity(), - null, - null, - attachmentObjectType, - attachmentObjectId, - UploadToolUtils.DEFAULT_MAX_BYTES); - bytes = fetched.bytes(); - if (StringUtils.isBlank(fileName)) { - fileName = fetched.fileName(); - } - } else if (hasBase64) { - if (StringUtils.isBlank(name)) { - throw new IllegalArgumentException("The 'name' parameter is mandatory when uploading from base64; it becomes the document file name (e.g. 'report.pdf')."); - } - bytes = UploadToolUtils.decodeBase64(base64); - } else { - UploadToolUtils.FetchedContent fetched = UploadToolUtils.fetchUrl(url, UploadToolUtils.DEFAULT_MAX_BYTES, name); - bytes = fetched.bytes(); - if (StringUtils.isBlank(fileName)) { - fileName = fetched.fileName(); + try { + if (hasAttachment) { + // Reuse UploadToolUtils.resolveImage's attachment branch: it resolves the + // referenced file to its raw bytes as the current user (ACL enforced) and, + // unlike its base64/url branches, does NOT constrain to image mime types — + // so any attached file works. Pass null url/base64 to stay on that branch. + UploadToolUtils.FetchedContent fetched = UploadToolUtils.resolveImage(socialAttachmentService, + fileService, + getCurrentUserAclIdentity(), + null, + null, + attachmentObjectType, + attachmentObjectId, + UploadToolUtils.DEFAULT_MAX_BYTES); + bytes = fetched.bytes(); + if (StringUtils.isBlank(fileName)) { + fileName = fetched.fileName(); + } + } else if (hasBase64) { + if (StringUtils.isBlank(name)) { + throw new IllegalArgumentException("The 'name' parameter is mandatory when uploading from base64; it becomes the document file name (e.g. 'report.pdf')."); + } + bytes = UploadToolUtils.decodeBase64(base64); + } else { + UploadToolUtils.FetchedContent fetched = UploadToolUtils.fetchUrl(url, UploadToolUtils.DEFAULT_MAX_BYTES, name); + bytes = fetched.bytes(); + if (StringUtils.isBlank(fileName)) { + fileName = fetched.fileName(); + } } + } catch (IllegalArgumentException | IllegalStateException e) { + // Already an LLM-directed message (bad source, HTTP error, SSRF block, + // size cap, invalid base64…): the MCP wrapper passes these through verbatim. + throw e; + } catch (RuntimeException e) { + // Any other unexpected failure of the resolve/fetch path: rethrow as an + // LLM-directed type so it isn't swallowed by the generic wrapper message. + throw new IllegalStateException("Could not read the file to upload from the given source: " + e.getMessage()); } return importContent(parentFolderId, fileName, bytes); } diff --git a/documents-services/src/main/resources/ai-tool-definitions.json b/documents-services/src/main/resources/ai-tool-definitions.json index c3d2bd0cf..d0dfead5c 100644 --- a/documents-services/src/main/resources/ai-tool-definitions.json +++ b/documents-services/src/main/resources/ai-tool-definitions.json @@ -399,7 +399,7 @@ { "name": "create_document", "title": "Create a text document in the DMS", - "description": "Creates a text-based document (markdown, HTML, plain text, CSV or JSON) from a content string authored in the chat. Use this to turn generated text into a real file in the Documents app. Call get_root_folder_for_user, get_root_folder_by_space or get_documents_by_folder_id first to get the parent_folder_id. The stored content type is derived from the file extension by the platform: use a registered extension (.txt, .html, .docx, .pdf, image types, etc.) so the file has the correct type and is readable via get_document_content_by_id; an unregistered extension like .md is stored as application/octet-stream and its text won't be extractable. For binary files (PDF, images, office files) use upload_document instead.", + "description": "Creates a TEXT-based document (plain text, markdown, HTML, CSV, JSON, XML) by writing the provided content string as-is into the file body. Use this to turn chat-generated text into a real file in the Documents app; use .txt, .md or .html for the file name. It CANNOT produce binary formats: office documents (.docx, .xlsx, .pptx, .odt, etc.) and PDF are structured packages, not text, so they cannot be created here — and neither can an empty template document; use upload_document with the real file bytes (base64, url or a chat attachment) for those. Call get_root_folder_for_user, get_root_folder_by_space or get_documents_by_folder_id first to get the parent_folder_id. The stored content type is derived from the file extension by the platform: use a registered text extension (.txt, .html, .csv, etc.) so the file has the correct type and is readable via get_document_content_by_id; an unregistered extension like .md is stored as application/octet-stream and its text won't be extractable.", "input_schema": { "type": "object", "properties": { diff --git a/documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java b/documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java index a48b1707b..16a86f76e 100644 --- a/documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java +++ b/documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java @@ -455,14 +455,56 @@ public void createDocumentAppendsExtensionAndImports() throws Exception { eq(USER_IDENTITY_ID)); } + @Test + public void createDocumentTextExtensionSucceeds() throws Exception { + when(documentFileService.getDocumentById(FOLDER_ID, USERNAME)).thenReturn(folderNode(FOLDER_ID)); + when(documentFileService.getRootFolderOwnerId(FOLDER_ID)).thenReturn(OWNER_ID); + when(documentFileService.getFolderChildNodes(any(), anyInt(), anyInt(), anyLong())) + .thenReturn(List.of(fileNodeNamed("doc-txt", "notes.txt"))); + + DocumentModel model = documentMcpTool.createDocument(FOLDER_ID, "notes.txt", "plain text body", "text/plain"); + + assertNotNull(model); + assertEquals("doc-txt", model.getId()); + verify(documentFileService).importFiles(eq(String.valueOf(OWNER_ID)), + eq(FOLDER_ID), + isNull(), + any(), + eq("rename"), + eq(currentIdentity), + eq(USER_IDENTITY_ID)); + } + @Test public void createDocumentBlankNameFails() { assertThrows(IllegalArgumentException.class, () -> documentMcpTool.createDocument(FOLDER_ID, " ", "body", null)); } @Test - public void createDocumentNullContentFails() { - assertThrows(IllegalArgumentException.class, () -> documentMcpTool.createDocument(FOLDER_ID, "notes.md", null, null)); + public void createDocumentOfficeExtensionFails() { + // .docx / .xlsx / .pptx / .pdf are structured/binary formats that cannot be + // built from a text string: fail up front with an LLM-directed message that + // names the extension and points to upload_document. + for (String name : new String[] { "report.docx", "budget.xlsx", "deck.pptx", "doc.pdf" }) { + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, + () -> documentMcpTool.createDocument(FOLDER_ID, name, "some content", null)); + String ext = name.substring(name.lastIndexOf('.') + 1); + assertTrue(ex.getMessage().contains("'" + ext + "'")); + assertTrue(ex.getMessage().contains("upload_document")); + } + } + + @Test + public void createDocumentBlankContentFails() { + IllegalArgumentException nullContent = + assertThrows(IllegalArgumentException.class, + () -> documentMcpTool.createDocument(FOLDER_ID, "notes.md", null, null)); + assertTrue(nullContent.getMessage().contains("content is required")); + IllegalArgumentException blankContent = + assertThrows(IllegalArgumentException.class, + () -> documentMcpTool.createDocument(FOLDER_ID, "notes.md", " ", null)); + assertTrue(blankContent.getMessage().contains("content is required")); } @Test From 5fd4b1003744412a67f4cc695289c6806761389c Mon Sep 17 00:00:00 2001 From: Benjamin Mestrallet Date: Tue, 7 Jul 2026 18:38:54 +0100 Subject: [PATCH 11/15] refactor: drop mime_type from create_document; require a filename with extension - EXO-88354 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../documents/mcp/DocumentMcpTool.java | 67 ++++--------------- .../main/resources/ai-tool-definitions.json | 8 +-- .../documents/mcp/DocumentMcpToolTest.java | 46 ++++++------- 3 files changed, 35 insertions(+), 86 deletions(-) diff --git a/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java b/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java index de089016e..ebf2235f0 100644 --- a/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java +++ b/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java @@ -351,33 +351,33 @@ public DocumentModel createFolder(String parentFolderId, /** * Creates a text-based document (markdown, HTML, plain text, CSV, JSON…) from * a chat-authored content string. This is the chat-native way to - * turn generated text into a real file in the Documents app. + * turn generated text into a real file in the Documents app. The + * name must include a file extension: the extension drives the + * stored content type via the platform MimeTypeResolver, so use a registered + * text extension (.txt, .html, .csv, .json, .xml…) so the file has the correct + * type and is readable via get_document_content_by_id. * * @param parentFolderId the folder where the document is created (get it from * get_root_folder_for_user / get_root_folder_by_space / * get_documents_by_folder_id) - * @param name the document file name (an extension is appended from the mime - * type when missing, e.g. 'notes' + text/markdown -> 'notes.md') + * @param name the document file name, including its extension (e.g. + * 'notes.md', 'report.txt', 'page.html'). The extension determines the + * file's content type (via the platform MimeTypeResolver); the name is + * used as-is. * @param content the text / markdown / HTML body - * @param mimeType optional mime type; only used to infer a file extension when - * the name has none (advisory). The stored content type is derived - * from the file extension by the platform MimeTypeResolver: use a - * registered extension (.txt, .html, .docx, .pdf, image types…) so the - * file has the correct type and is readable via - * get_document_content_by_id; an unregistered extension like .md is - * stored as application/octet-stream and its text won't be - * extractable. * @return the created document */ public DocumentModel createDocument(String parentFolderId, String name, - String content, - String mimeType) throws IllegalAccessException, ObjectNotFoundException { + String content) throws IllegalAccessException, ObjectNotFoundException { checkFolderIdParameter(parentFolderId); if (StringUtils.isBlank(name)) { throw new IllegalArgumentException("The 'name' parameter is mandatory to create a document (e.g. 'meeting-notes.md'). Ask the user for a document name."); } String extension = StringUtils.substringAfterLast(StringUtils.lowerCase(StringUtils.trimToEmpty(name)), "."); + if (StringUtils.isBlank(extension)) { + throw new IllegalArgumentException("The document name must include a file extension (e.g. 'notes.md', 'report.txt', 'page.html') — the extension determines the file's content type."); + } if (UNSUPPORTED_BINARY_EXTENSIONS.contains(extension)) { throw new IllegalArgumentException(("create_document writes text content as the file body, so it cannot produce a valid" + " '%s' file — office and PDF formats are structured packages, not text. Creating an empty office document from a" @@ -387,9 +387,7 @@ public DocumentModel createDocument(String parentFolderId, if (StringUtils.isBlank(content)) { throw new IllegalArgumentException("content is required to create a document (the text to write into the file)."); } - String resolvedMime = resolveMimeType(name, mimeType); - String fileName = ensureExtension(name, resolvedMime); - return importContent(parentFolderId, fileName, content.getBytes(StandardCharsets.UTF_8)); + return importContent(parentFolderId, name, content.getBytes(StandardCharsets.UTF_8)); } /** @@ -831,43 +829,6 @@ private static String sanitizeFileName(String fileName) { return cleaned.isEmpty() ? "document" : cleaned; } - private static String resolveMimeType(String name, String provided) { - if (StringUtils.isNotBlank(provided)) { - return provided; - } - String lower = StringUtils.lowerCase(name); - if (lower == null) { - return "text/plain"; - } else if (lower.endsWith(".md") || lower.endsWith(".markdown")) { - return "text/markdown"; - } else if (lower.endsWith(".html") || lower.endsWith(".htm")) { - return "text/html"; - } else if (lower.endsWith(".csv")) { - return "text/csv"; - } else if (lower.endsWith(".json")) { - return "application/json"; - } else if (lower.endsWith(".xml")) { - return "application/xml"; - } else { - return "text/plain"; - } - } - - private static String ensureExtension(String name, String mimeType) { - if (StringUtils.contains(name, '.')) { - return name; - } - String extension = switch (StringUtils.lowerCase(StringUtils.trimToEmpty(mimeType))) { - case "text/markdown" -> ".md"; - case "text/html" -> ".html"; - case "text/csv" -> ".csv"; - case "application/json" -> ".json"; - case "application/xml" -> ".xml"; - default -> ".txt"; - }; - return name + extension; - } - private static void sleepQuietly(long millis) { try { Thread.sleep(millis); diff --git a/documents-services/src/main/resources/ai-tool-definitions.json b/documents-services/src/main/resources/ai-tool-definitions.json index d0dfead5c..0fe7c8405 100644 --- a/documents-services/src/main/resources/ai-tool-definitions.json +++ b/documents-services/src/main/resources/ai-tool-definitions.json @@ -399,7 +399,7 @@ { "name": "create_document", "title": "Create a text document in the DMS", - "description": "Creates a TEXT-based document (plain text, markdown, HTML, CSV, JSON, XML) by writing the provided content string as-is into the file body. Use this to turn chat-generated text into a real file in the Documents app; use .txt, .md or .html for the file name. It CANNOT produce binary formats: office documents (.docx, .xlsx, .pptx, .odt, etc.) and PDF are structured packages, not text, so they cannot be created here — and neither can an empty template document; use upload_document with the real file bytes (base64, url or a chat attachment) for those. Call get_root_folder_for_user, get_root_folder_by_space or get_documents_by_folder_id first to get the parent_folder_id. The stored content type is derived from the file extension by the platform: use a registered text extension (.txt, .html, .csv, etc.) so the file has the correct type and is readable via get_document_content_by_id; an unregistered extension like .md is stored as application/octet-stream and its text won't be extractable.", + "description": "Creates a TEXT-based document (plain text, markdown, HTML, CSV, JSON, XML) by writing the provided content string as-is into the file body. Use this to turn chat-generated text into a real file in the Documents app. The name must be a filename WITH an extension (e.g. 'notes.md', 'report.txt', 'page.html') — the extension sets the file's content type. It writes text as the file body, so use a text extension (.txt, .md, .html). It CANNOT produce binary formats: office documents (.docx, .xlsx, .pptx, .odt, etc.) and PDF are structured packages, not text, so they cannot be created here — and neither can an empty template document; use upload_document with the real file bytes (base64, url or a chat attachment) for those. Call get_root_folder_for_user, get_root_folder_by_space or get_documents_by_folder_id first to get the parent_folder_id. The stored content type is derived from the file extension by the platform: use a registered text extension (.txt, .html, .csv, etc.) so the file has the correct type and is readable via get_document_content_by_id; an unregistered extension like .md is stored as application/octet-stream and its text won't be extractable.", "input_schema": { "type": "object", "properties": { @@ -409,15 +409,11 @@ }, "name": { "type": "string", - "description": "The document file name. If no extension is given, one is appended based on the mime type (e.g. 'notes' with text/markdown becomes 'notes.md')." + "description": "The document file name, including its extension (e.g. 'notes.md', 'report.txt', 'page.html'). The extension determines the file's content type; the name is used as-is." }, "content": { "type": "string", "description": "The text, markdown or HTML body of the document." - }, - "mime_type": { - "type": "string", - "description": "Optional, advisory. Only used to infer a file extension when the name has none; the stored content type is derived from the file extension by the platform, not from this value." } }, "required": [ diff --git a/documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java b/documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java index 16a86f76e..aafbd2057 100644 --- a/documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java +++ b/documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java @@ -433,28 +433,6 @@ private FileNode fileNodeNamed(String id, String name) { return fileNode; } - @Test - public void createDocumentAppendsExtensionAndImports() throws Exception { - when(documentFileService.getDocumentById(FOLDER_ID, USERNAME)).thenReturn(folderNode(FOLDER_ID)); - when(documentFileService.getRootFolderOwnerId(FOLDER_ID)).thenReturn(OWNER_ID); - // 'notes' has no extension: with text/markdown it becomes 'notes.md'. - when(documentFileService.getFolderChildNodes(any(), anyInt(), anyInt(), anyLong())) - .thenReturn(List.of(fileNodeNamed("doc-created", "notes.md"))); - - DocumentModel model = documentMcpTool.createDocument(FOLDER_ID, "notes", "# Hello", "text/markdown"); - - assertNotNull(model); - assertEquals("doc-created", model.getId()); - // folderPath must be null (parent resolved from the folder id) and conflict "rename". - verify(documentFileService).importFiles(eq(String.valueOf(OWNER_ID)), - eq(FOLDER_ID), - isNull(), - any(), - eq("rename"), - eq(currentIdentity), - eq(USER_IDENTITY_ID)); - } - @Test public void createDocumentTextExtensionSucceeds() throws Exception { when(documentFileService.getDocumentById(FOLDER_ID, USERNAME)).thenReturn(folderNode(FOLDER_ID)); @@ -462,10 +440,12 @@ public void createDocumentTextExtensionSucceeds() throws Exception { when(documentFileService.getFolderChildNodes(any(), anyInt(), anyInt(), anyLong())) .thenReturn(List.of(fileNodeNamed("doc-txt", "notes.txt"))); - DocumentModel model = documentMcpTool.createDocument(FOLDER_ID, "notes.txt", "plain text body", "text/plain"); + // The name is used as-is (it already carries a .txt extension). + DocumentModel model = documentMcpTool.createDocument(FOLDER_ID, "notes.txt", "plain text body"); assertNotNull(model); assertEquals("doc-txt", model.getId()); + // folderPath must be null (parent resolved from the folder id) and conflict "rename". verify(documentFileService).importFiles(eq(String.valueOf(OWNER_ID)), eq(FOLDER_ID), isNull(), @@ -477,7 +457,19 @@ public void createDocumentTextExtensionSucceeds() throws Exception { @Test public void createDocumentBlankNameFails() { - assertThrows(IllegalArgumentException.class, () -> documentMcpTool.createDocument(FOLDER_ID, " ", "body", null)); + assertThrows(IllegalArgumentException.class, () -> documentMcpTool.createDocument(FOLDER_ID, " ", "body")); + } + + @Test + public void createDocumentMissingExtensionFails() { + // A name with no extension (and a trailing-dot name, which also has no + // extension) must fail: the extension drives the stored content type. + for (String name : new String[] { "notes", "my.notes.", "report " }) { + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, + () -> documentMcpTool.createDocument(FOLDER_ID, name, "some content")); + assertTrue(ex.getMessage().contains("must include a file extension")); + } } @Test @@ -488,7 +480,7 @@ public void createDocumentOfficeExtensionFails() { for (String name : new String[] { "report.docx", "budget.xlsx", "deck.pptx", "doc.pdf" }) { IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, - () -> documentMcpTool.createDocument(FOLDER_ID, name, "some content", null)); + () -> documentMcpTool.createDocument(FOLDER_ID, name, "some content")); String ext = name.substring(name.lastIndexOf('.') + 1); assertTrue(ex.getMessage().contains("'" + ext + "'")); assertTrue(ex.getMessage().contains("upload_document")); @@ -499,11 +491,11 @@ public void createDocumentOfficeExtensionFails() { public void createDocumentBlankContentFails() { IllegalArgumentException nullContent = assertThrows(IllegalArgumentException.class, - () -> documentMcpTool.createDocument(FOLDER_ID, "notes.md", null, null)); + () -> documentMcpTool.createDocument(FOLDER_ID, "notes.md", null)); assertTrue(nullContent.getMessage().contains("content is required")); IllegalArgumentException blankContent = assertThrows(IllegalArgumentException.class, - () -> documentMcpTool.createDocument(FOLDER_ID, "notes.md", " ", null)); + () -> documentMcpTool.createDocument(FOLDER_ID, "notes.md", " ")); assertTrue(blankContent.getMessage().contains("content is required")); } From cf7146ab42e10b2ad743d7f772fe24d201610940 Mon Sep 17 00:00:00 2001 From: Benjamin Mestrallet Date: Tue, 7 Jul 2026 18:58:36 +0100 Subject: [PATCH 12/15] feat: add version, share, public-link, shortcut and favorite Documents MCP tools Adds six new MCP tools to DocumentMcpTool, each acting as the current user (ACLs enforced) with a matching ai-tool-definitions.json entry and tests: - add_document_version: uploads updated content onto an existing file as a new version via DocumentFileService.createNewVersion (checkout -> set jcr:content -> checkin/VersionHistoryUtils.createVersion), from text/base64/url/chat attachment, with an optional version summary stamped on the new current version. - create_public_link: PublicDocumentAccessService.createPublicDocumentAccess, returning the /portal/download-document/{nodeId} shareable link. - create_document_shortcut: DocumentFileService.createShortcut into a resolved destination folder path. - share_document: DocumentFileService.shareDocument with a user or a space. - favorite_document / unfavorite_document: social FavoriteService with the "document"/"folder" object types. All writes require approval and pre-check edit permission where applicable. Skipped by design: list_trash (admin-only global trash, no per-user scope), share permission levels (service exposes no clean granular API), and create_document_from_template (still no ECMS service method; tracked on #88422). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../documents/mcp/DocumentMcpTool.java | 302 +++++++++++++++++- .../mcp/model/DocumentPublicLinkModel.java | 32 ++ .../main/resources/ai-tool-definitions.json | 197 ++++++++++++ .../documents/mcp/DocumentMcpToolTest.java | 214 ++++++++++++- 4 files changed, 743 insertions(+), 2 deletions(-) create mode 100644 documents-services/src/main/java/org/exoplatform/documents/mcp/model/DocumentPublicLinkModel.java diff --git a/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java b/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java index ebf2235f0..783606f20 100644 --- a/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java +++ b/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java @@ -19,9 +19,11 @@ import static io.meeds.mcp.server.tool.util.McpToolPluginUtils.getInteger; import static io.meeds.mcp.server.util.McpToolUtils.formatDate; +import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.util.Date; import java.util.List; import java.util.Set; import java.util.zip.ZipEntry; @@ -40,6 +42,7 @@ import org.exoplatform.documents.mcp.model.DocumentFileModel; import org.exoplatform.documents.mcp.model.DocumentFolderModel; import org.exoplatform.documents.mcp.model.DocumentModel; +import org.exoplatform.documents.mcp.model.DocumentPublicLinkModel; import org.exoplatform.documents.mcp.model.DocumentTreeItemModel; import org.exoplatform.documents.mcp.model.DocumentVersionModel; import org.exoplatform.documents.mcp.model.DocumentsSizeModel; @@ -52,7 +55,9 @@ import org.exoplatform.documents.model.FileVersion; import org.exoplatform.documents.model.FolderNode; import org.exoplatform.documents.model.FullTreeItem; +import org.exoplatform.documents.model.PublicDocumentAccess; import org.exoplatform.documents.service.DocumentFileService; +import org.exoplatform.documents.service.PublicDocumentAccessService; import org.exoplatform.portal.config.UserACL; import org.exoplatform.portal.config.UserPortalConfigService; import org.exoplatform.services.attachments.service.AttachmentService; @@ -61,6 +66,8 @@ import org.exoplatform.social.core.profileproperty.ProfilePropertyService; import org.exoplatform.social.core.space.model.Space; import org.exoplatform.social.core.space.spi.SpaceService; +import org.exoplatform.social.metadata.favorite.FavoriteService; +import org.exoplatform.social.metadata.favorite.model.Favorite; import org.exoplatform.upload.UploadService; import org.exoplatform.wiki.WikiException; import org.exoplatform.wiki.model.Page; @@ -89,6 +96,20 @@ public class DocumentMcpTool implements McpToolPlugin { public static final String FILE_URL_FORMAT = "/portal/dw/documents?documentPreviewId=%s"; + /** + * Path of the anonymous public-download portal page (global page + * download-document); the document node id is appended as an + * extra path segment, mirroring how the Documents front-end builds the + * shareable link ({origin}/{containerName}/download-document/{nodeId}). + */ + public static final String PUBLIC_LINK_URL_FORMAT = "/portal/download-document/%s"; + + /** Favorite object type for files, matching {@code DocumentAclPlugin.OBJECT_TYPE}. */ + private static final String FAVORITE_TYPE_DOCUMENT = "document"; + + /** Favorite object type for folders, matching {@code FolderAclPlugin.OBJECT_TYPE}. */ + private static final String FAVORITE_TYPE_FOLDER = "folder"; + /** * Structured / binary office and PDF formats that cannot be produced from a * plain text string: they are zipped packages (OOXML/ODF) or a binary layout @@ -128,6 +149,10 @@ public class DocumentMcpTool implements McpToolPlugin { private final UploadService uploadService; + private final PublicDocumentAccessService publicDocumentAccessService; + + private final FavoriteService favoriteService; + public DocumentMcpTool(DocumentFileService documentFileService, AttachmentService attachmentService, org.exoplatform.social.attachment.AttachmentService socialAttachmentService, @@ -138,7 +163,9 @@ public DocumentMcpTool(DocumentFileService documentFileService, ProfilePropertyService profilePropertyService, UserACL userAcl, UserPortalConfigService portalConfigService, - UploadService uploadService) { + UploadService uploadService, + PublicDocumentAccessService publicDocumentAccessService, + FavoriteService favoriteService) { this.documentFileService = documentFileService; this.attachmentService = attachmentService; this.socialAttachmentService = socialAttachmentService; @@ -150,6 +177,8 @@ public DocumentMcpTool(DocumentFileService documentFileService, this.userAcl = userAcl; this.portalConfigService = portalConfigService; this.uploadService = uploadService; + this.publicDocumentAccessService = publicDocumentAccessService; + this.favoriteService = favoriteService; } public DocumentFolderModel getRootFolderBySpace(long spaceId) throws ObjectNotFoundException, IllegalAccessException { @@ -570,6 +599,277 @@ public void setDocumentVisibility(String documentId, Boolean hidden) throws Ille documentFileService.setDocumentVisibility(getOwnerId(documentId), documentId, hidden, getCurrentUserIdentityId()); } + // --------------------------------------------------------------------------- + // Versioning, sharing, public links and favorites (writes, require approval) + // --------------------------------------------------------------------------- + + /** + * Uploads updated content onto an EXISTING file, creating a NEW version of it + * (the previous content stays retrievable via list_document_versions / + * restore_document_version). The new bytes come from exactly one source: a + * chat-authored text content string, a base64 + * payload, an http(s) url, or a file the user attached in the + * conversation. The file's name and type are unchanged; only its content and + * version history are updated. + * + * @param documentId the id of the existing file to add a version to (get it + * from get_document_by_id / get_documents_by_folder_id / + * search_documents) + * @param content plain text / markdown / HTML body to write as the new version + * (use for text files) + * @param base64 the new file bytes encoded in base64 + * @param url an http(s) URL to download the new content from (fetched + * server-side behind an SSRF guard) + * @param attachmentObjectType the object type of the file the user attached in + * chat; set automatically by the client — do not fill it from the model + * @param attachmentObjectId the object id of the file the user attached in + * chat; set automatically by the client — do not fill it from the model + * @param versionSummary an optional change note stored on the new version + * @return the updated document (with its refreshed size / modification info) + */ + public DocumentModel addDocumentVersion(String documentId, + String content, + String base64, + String url, + String attachmentObjectType, + String attachmentObjectId, + String versionSummary) throws IllegalAccessException, ObjectNotFoundException { + AbstractNode node = checkCanAccessDocument(documentId); + if (!(node instanceof FileNode)) { + throw new IllegalArgumentException("Only a file can have versions; the id %s points to a folder. Provide a file document_id." + .formatted(documentId)); + } + if (!documentFileService.hasEditPermissionOnDocument(documentId, getCurrentUserIdentityId())) { + throw new IllegalAccessException("You are not allowed to edit the document %s, so you cannot add a new version to it." + .formatted(documentId)); + } + byte[] bytes = resolveContentBytes(content, base64, url, attachmentObjectType, attachmentObjectId); + documentFileService.createNewVersion(documentId, getCurrentUserName(), new ByteArrayInputStream(bytes)); + if (StringUtils.isNotBlank(versionSummary)) { + applyVersionSummary(documentId, versionSummary); + } + return getDocumentById(documentId); + } + + /** + * Creates (or refreshes) an anonymous public share link for a document so it + * can be downloaded without signing in, optionally protected by a password + * and/or an expiration date. Requires edit permission on the document. + * + * @param documentId the id of the file to share publicly (get it from the read + * tools) + * @param password an optional password required to open the link + * @param expirationDate an optional expiration date, as epoch milliseconds; + * after it the link stops working. Leave blank for a link that never + * expires. + * @return the shareable link, together with its password and expiration + */ + public DocumentPublicLinkModel createPublicLink(String documentId, + String password, + Long expirationDate) throws IllegalAccessException, + ObjectNotFoundException { + checkCanAccessDocument(documentId); + long userIdentityId = getCurrentUserIdentityId(); + if (!documentFileService.hasEditPermissionOnDocument(documentId, userIdentityId)) { + throw new IllegalAccessException("You are not allowed to edit the document %s, so you cannot create a public link for it." + .formatted(documentId)); + } + long expiration = expirationDate == null ? 0L : expirationDate.longValue(); + PublicDocumentAccess access = publicDocumentAccessService.createPublicDocumentAccess(userIdentityId, + documentId, + StringUtils.isBlank(password) ? null + : password, + expiration, + false); + if (access == null) { + throw new IllegalStateException("The public link could not be created for document %s. Verify the document id and retry." + .formatted(documentId)); + } + Date expirationValue = access.getExpirationDate(); + return new DocumentPublicLinkModel(documentId, + PUBLIC_LINK_URL_FORMAT.formatted(documentId), + access.getDecodedPassword(), + expirationValue == null ? null : formatDate(expirationValue)); + } + + /** + * Creates a shortcut (symlink) to a document inside another folder, so the same + * file appears in a second location without being copied. + * + * @param documentId the id of the file or folder to create a shortcut to + * @param destinationFolderId the id of the folder where the shortcut is placed + * (get it from get_root_folder_for_user / get_root_folder_by_space / + * get_documents_by_folder_id) + * @param conflictAction behaviour when a node with the same name already exists + * in the destination; defaults to 'rename' + */ + public void createDocumentShortcut(String documentId, + String destinationFolderId, + String conflictAction) throws IllegalAccessException, ObjectNotFoundException { + checkDocumentIdParameter(documentId); + if (StringUtils.isBlank(destinationFolderId)) { + throw new IllegalArgumentException("The 'destinationFolderId' parameter is mandatory. Call get_documents_by_folder_id or get_root_folder_for_user to find the target folder id."); + } + // Validate the source and the destination folder exist and are readable by + // the user before touching the JCR storage. + checkCanAccessDocument(documentId); + AbstractNode destination = getNode(destinationFolderId); + try { + documentFileService.createShortcut(documentId, + destination.getPath(), + getCurrentUserName(), + StringUtils.isBlank(conflictAction) ? "rename" : conflictAction); + } catch (ObjectAlreadyExistsException e) { + throw new IllegalStateException("A node with the same name already exists in the destination folder. Retry with conflict_action set to 'rename'."); + } + } + + /** + * Shares a document with another user or with a space: the recipient gets the + * file in their "Shared" folder with the document's own permissions. Provide + * exactly one of username or spaceId. Requires edit + * permission on the document. + * + * @param documentId the id of the file to share (get it from the read tools) + * @param username the username to share the document with (mutually exclusive + * with space_id) + * @param spaceId the id of the space to share the document with (mutually + * exclusive with username) + * @param notify whether to notify the recipient; defaults to true + */ + public void shareDocument(String documentId, + String username, + Long spaceId, + Boolean notify) throws IllegalAccessException, ObjectNotFoundException { + checkCanAccessDocument(documentId); + boolean hasUser = StringUtils.isNotBlank(username); + boolean hasSpace = spaceId != null && spaceId > 0; + if (hasUser == hasSpace) { + throw new IllegalArgumentException("Provide exactly one recipient: either 'username' or 'space_id', not both and not none."); + } + if (!documentFileService.hasEditPermissionOnDocument(documentId, getCurrentUserIdentityId())) { + throw new IllegalAccessException("You are not allowed to edit the document %s, so you cannot share it.".formatted(documentId)); + } + long destId; + if (hasUser) { + Identity userIdentity = identityManager.getOrCreateUserIdentity(username); + if (userIdentity == null) { + throw new ObjectNotFoundException("No user found with username '%s'. Use search_users to find the exact username." + .formatted(username)); + } + destId = userIdentity.getIdentityId(); + } else { + Space space = spaceService.getSpaceById(String.valueOf(spaceId)); + if (space == null) { + throw new ObjectNotFoundException("Space with id %s not found. Use get_my_spaces to check accessible spaces." + .formatted(spaceId)); + } + destId = identityManager.getOrCreateSpaceIdentity(space.getPrettyName()).getIdentityId(); + } + documentFileService.shareDocument(documentId, destId, getCurrentUserIdentityId(), notify == null || notify.booleanValue()); + } + + /** + * Adds a document (file or folder) to the current user's favorites. + * + * @param documentId the id of the file or folder to mark as favorite + */ + public void favoriteDocument(String documentId) throws IllegalAccessException, ObjectNotFoundException { + Favorite favorite = toFavorite(documentId); + try { + favoriteService.createFavorite(favorite); + } catch (ObjectAlreadyExistsException e) { + throw new IllegalStateException("The document %s is already in the user's favorites.".formatted(documentId)); + } + } + + /** + * Removes a document (file or folder) from the current user's favorites. + * + * @param documentId the id of the file or folder to remove from favorites + */ + public void unfavoriteDocument(String documentId) throws IllegalAccessException, ObjectNotFoundException { + Favorite favorite = toFavorite(documentId); + try { + favoriteService.deleteFavorite(favorite); + } catch (ObjectNotFoundException e) { + throw new IllegalStateException("The document %s is not in the user's favorites, so it cannot be removed from them." + .formatted(documentId)); + } + } + + private Favorite toFavorite(String documentId) throws IllegalAccessException, ObjectNotFoundException { + AbstractNode node = checkCanAccessDocument(documentId); + String objectType = node instanceof FileNode ? FAVORITE_TYPE_DOCUMENT : FAVORITE_TYPE_FOLDER; + return new Favorite(objectType, documentId, null, getCurrentUserIdentityId()); + } + + /** + * Resolves the raw bytes for a new file version from exactly one of the + * supported sources (chat text, base64, url or a chat attachment), reusing + * {@code upload_document}'s attachment/url resolution so the same ACL and SSRF + * guards apply. + */ + private byte[] resolveContentBytes(String content, + String base64, + String url, + String attachmentObjectType, + String attachmentObjectId) throws IllegalAccessException, ObjectNotFoundException { + boolean hasContent = StringUtils.isNotBlank(content); + boolean hasBase64 = StringUtils.isNotBlank(base64); + boolean hasUrl = StringUtils.isNotBlank(url); + boolean hasAttachment = StringUtils.isNotBlank(attachmentObjectId); + int sources = (hasContent ? 1 : 0) + (hasBase64 ? 1 : 0) + (hasUrl ? 1 : 0) + (hasAttachment ? 1 : 0); + if (sources == 0) { + throw new IllegalArgumentException(""" + Provide the new version content from exactly one source: + a text 'content' string, a 'base64' payload, an http(s) 'url', or a file the user attached in the conversation. + """); + } + if (sources > 1) { + throw new IllegalArgumentException("Provide the new version content from only one source: 'content', 'base64', 'url' or the chat attachment — not several."); + } + try { + if (hasContent) { + return content.getBytes(StandardCharsets.UTF_8); + } else if (hasBase64) { + return UploadToolUtils.decodeBase64(base64); + } else if (hasAttachment) { + return UploadToolUtils.resolveImage(socialAttachmentService, + fileService, + getCurrentUserAclIdentity(), + null, + null, + attachmentObjectType, + attachmentObjectId, + UploadToolUtils.DEFAULT_MAX_BYTES) + .bytes(); + } else { + return UploadToolUtils.fetchUrl(url, UploadToolUtils.DEFAULT_MAX_BYTES, null).bytes(); + } + } catch (IllegalArgumentException | IllegalStateException e) { + throw e; + } catch (RuntimeException e) { + throw new IllegalStateException("Could not read the new version content from the given source: " + e.getMessage()); + } + } + + /** + * Stamps the summary onto the version that {@code createNewVersion} just made: + * that storage call returns an empty {@link FileVersion}, so the current + * version is looked up and its summary updated. + */ + private void applyVersionSummary(String documentId, String versionSummary) { + List versions = documentFileService.getFileVersions(documentId, getCurrentUserName()); + versions.stream() + .filter(FileVersion::isCurrent) + .findFirst() + .ifPresent(current -> documentFileService.updateVersionSummary(documentId, + current.getId(), + versionSummary, + getCurrentUserName())); + } + private UserModel toUserModel(long identityId) { Identity identity = identityManager.getIdentity(identityId); if (identity == null || !identity.isUser()) { diff --git a/documents-services/src/main/java/org/exoplatform/documents/mcp/model/DocumentPublicLinkModel.java b/documents-services/src/main/java/org/exoplatform/documents/mcp/model/DocumentPublicLinkModel.java new file mode 100644 index 000000000..047c5aae4 --- /dev/null +++ b/documents-services/src/main/java/org/exoplatform/documents/mcp/model/DocumentPublicLinkModel.java @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2026 eXo Platform SAS. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.exoplatform.documents.mcp.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * A public (anonymous) share link created for a document, together with its + * optional password and expiration date. + */ +@JsonInclude(Include.NON_EMPTY) +public record DocumentPublicLinkModel(@JsonProperty("document_id") String documentId, + @JsonProperty("url") String url, + @JsonProperty("password") String password, + @JsonProperty("expiration_date") String expirationDate) { +} diff --git a/documents-services/src/main/resources/ai-tool-definitions.json b/documents-services/src/main/resources/ai-tool-definitions.json index 0fe7c8405..6bb0d078b 100644 --- a/documents-services/src/main/resources/ai-tool-definitions.json +++ b/documents-services/src/main/resources/ai-tool-definitions.json @@ -727,6 +727,203 @@ "openWorldHint": false }, "require_approval": true + }, + { + "name": "add_document_version", + "title": "Add a new version to a DMS file", + "description": "Uploads updated content onto an EXISTING file, creating a new version (the previous content stays retrievable via list_document_versions / restore_document_version). The file name and type are unchanged. Provide the new content from exactly one source: a text 'content' string (for text files), a 'base64' payload, an http(s) 'url', or a file the user attached in the conversation. Call get_document_by_id, get_documents_by_folder_id or search_documents first to get the document_id.", + "input_schema": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "The unique identifier of the existing file to add a version to." + }, + "content": { + "type": "string", + "description": "Plain text / markdown / HTML body to write as the new version. Use for text files. Provide exactly one source: content, base64, url or the chat attachment." + }, + "base64": { + "type": "string", + "description": "The new file bytes encoded in base64. Provide exactly one source: content, base64, url or the chat attachment." + }, + "url": { + "type": "string", + "description": "An http(s) URL to download the new content from (fetched server-side behind an SSRF guard). Provide exactly one source: content, base64, url or the chat attachment." + }, + "attachment_object_type": { + "type": "string", + "description": "Internal: object type of an existing platform attachment to use as the new version (set automatically by the client from the file the user attached in the conversation - do not fill this yourself). Read as the current user, so its ACL is enforced." + }, + "attachment_object_id": { + "type": "string", + "description": "Internal: object id of an existing platform attachment to use as the new version (set automatically by the client - do not fill this yourself). Provide exactly one source: content, base64, url or attachment_object_id." + }, + "version_summary": { + "type": "string", + "description": "Optional change note stored on the new version." + } + }, + "required": [ + "document_id" + ] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "require_approval": true + }, + { + "name": "create_public_link", + "title": "Create a public download link for a DMS file", + "description": "Creates (or refreshes) an anonymous public share link for a document so it can be downloaded without signing in, optionally protected by a password and/or an expiration date. Requires edit permission on the document. Call get_document_by_id or get_documents_by_folder_id first to get the document_id.", + "input_schema": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "The unique identifier of the file to share publicly." + }, + "password": { + "type": "string", + "description": "Optional password required to open the link." + }, + "expiration_date": { + "type": "integer", + "description": "Optional expiration date as epoch milliseconds; after it the link stops working. Leave blank for a link that never expires." + } + }, + "required": [ + "document_id" + ] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "require_approval": true + }, + { + "name": "create_document_shortcut", + "title": "Create a shortcut to a DMS file or folder", + "description": "Creates a shortcut (symlink) to a document inside another folder, so the same file appears in a second location without being copied. Call get_document_by_id or get_documents_by_folder_id first to get the document_id and the destination_folder_id.", + "input_schema": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "The unique identifier of the file or folder to create a shortcut to." + }, + "destination_folder_id": { + "type": "string", + "description": "The unique identifier of the folder where the shortcut is placed." + }, + "conflict_action": { + "type": "string", + "description": "Behaviour when a node with the same name already exists in the destination. Defaults to 'rename'." + } + }, + "required": [ + "document_id", + "destination_folder_id" + ] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "require_approval": true + }, + { + "name": "share_document", + "title": "Share a DMS file with a user or space", + "description": "Shares a document with another user or with a space: the recipient gets the file in their 'Shared' folder with the document's own permissions. Provide exactly one recipient: either username or space_id. Requires edit permission on the document. Call get_document_by_id first to get the document_id, search_users for a username, or get_my_spaces for a space_id.", + "input_schema": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "The unique identifier of the file to share." + }, + "username": { + "type": "string", + "description": "The username to share the document with (mutually exclusive with space_id)." + }, + "space_id": { + "type": "integer", + "description": "The id of the space to share the document with (mutually exclusive with username)." + }, + "notify": { + "type": "boolean", + "description": "Whether to notify the recipient. Defaults to true." + } + }, + "required": [ + "document_id" + ] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "require_approval": true + }, + { + "name": "favorite_document", + "title": "Add a DMS file or folder to favorites", + "description": "Adds a document (file or folder) to the current user's favorites. Call get_document_by_id or get_documents_by_folder_id first to get the document_id.", + "input_schema": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "The unique identifier of the file or folder to mark as favorite." + } + }, + "required": [ + "document_id" + ] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "require_approval": true + }, + { + "name": "unfavorite_document", + "title": "Remove a DMS file or folder from favorites", + "description": "Removes a document (file or folder) from the current user's favorites. Call get_document_by_id or get_documents_by_folder_id first to get the document_id.", + "input_schema": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "The unique identifier of the file or folder to remove from favorites." + } + }, + "required": [ + "document_id" + ] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false + }, + "require_approval": true } ] } diff --git a/documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java b/documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java index aafbd2057..1a1118232 100644 --- a/documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java +++ b/documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java @@ -26,6 +26,7 @@ import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -38,11 +39,13 @@ import org.junit.Test; import org.mockito.Mockito; +import org.exoplatform.commons.ObjectAlreadyExistsException; import org.exoplatform.commons.exception.ObjectNotFoundException; import org.exoplatform.documents.mcp.model.BreadcrumbItemModel; import org.exoplatform.documents.mcp.model.DocumentFileModel; import org.exoplatform.documents.mcp.model.DocumentFolderModel; import org.exoplatform.documents.mcp.model.DocumentModel; +import org.exoplatform.documents.mcp.model.DocumentPublicLinkModel; import org.exoplatform.documents.mcp.model.DocumentTreeItemModel; import org.exoplatform.documents.mcp.model.DocumentVersionModel; import org.exoplatform.documents.mcp.model.DocumentsSizeModel; @@ -53,7 +56,9 @@ import org.exoplatform.documents.model.FileVersion; import org.exoplatform.documents.model.FolderNode; import org.exoplatform.documents.model.FullTreeItem; +import org.exoplatform.documents.model.PublicDocumentAccess; import org.exoplatform.documents.service.DocumentFileService; +import org.exoplatform.documents.service.PublicDocumentAccessService; import org.exoplatform.portal.config.UserACL; import org.exoplatform.portal.config.UserPortalConfigService; import org.exoplatform.services.attachments.service.AttachmentService; @@ -62,6 +67,8 @@ import org.exoplatform.social.core.profileproperty.ProfilePropertyService; import org.exoplatform.social.core.space.model.Space; import org.exoplatform.social.core.space.spi.SpaceService; +import org.exoplatform.social.metadata.favorite.FavoriteService; +import org.exoplatform.social.metadata.favorite.model.Favorite; import org.exoplatform.upload.UploadService; import io.meeds.social.translation.service.TranslationService; @@ -90,6 +97,10 @@ public class DocumentMcpToolTest { private SpaceService spaceService; + private PublicDocumentAccessService publicDocumentAccessService; + + private FavoriteService favoriteService; + private Identity currentIdentity; private DocumentMcpTool documentMcpTool; @@ -107,6 +118,8 @@ public void setUp() { UserACL userAcl = Mockito.mock(UserACL.class); UserPortalConfigService portalConfigService = Mockito.mock(UserPortalConfigService.class); UploadService uploadService = Mockito.mock(UploadService.class); + publicDocumentAccessService = Mockito.mock(PublicDocumentAccessService.class); + favoriteService = Mockito.mock(FavoriteService.class); currentIdentity = new Identity(USERNAME); org.exoplatform.social.core.identity.model.Identity socialIdentity = @@ -123,7 +136,9 @@ public void setUp() { profilePropertyService, userAcl, portalConfigService, - uploadService) { + uploadService, + publicDocumentAccessService, + favoriteService) { @Override public Identity getCurrentUserAclIdentity() { return currentIdentity; @@ -698,4 +713,201 @@ public void setDocumentVisibilityNullHiddenFails() { assertThrows(IllegalArgumentException.class, () -> documentMcpTool.setDocumentVisibility(DOCUMENT_ID, null)); } + // --------------------------------------------------------------------------- + // Versioning, sharing, public links and favorites + // --------------------------------------------------------------------------- + + @Test + public void addDocumentVersionFromContent() throws Exception { + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(fileNode(DOCUMENT_ID, "text/plain")); + when(documentFileService.hasEditPermissionOnDocument(DOCUMENT_ID, USER_IDENTITY_ID)).thenReturn(true); + + DocumentModel model = documentMcpTool.addDocumentVersion(DOCUMENT_ID, "updated body", null, null, null, null, null); + + assertNotNull(model); + assertEquals(DOCUMENT_ID, model.getId()); + verify(documentFileService).createNewVersion(eq(DOCUMENT_ID), eq(USERNAME), any()); + // No summary provided -> the version summary is not touched. + verify(documentFileService, Mockito.never()).updateVersionSummary(any(), any(), any(), any()); + } + + @Test + public void addDocumentVersionWithSummaryStampsCurrentVersion() throws Exception { + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(fileNode(DOCUMENT_ID, "text/plain")); + when(documentFileService.hasEditPermissionOnDocument(DOCUMENT_ID, USER_IDENTITY_ID)).thenReturn(true); + when(documentFileService.getFileVersions(DOCUMENT_ID, USERNAME)).thenReturn(List.of(fileVersion())); + + documentMcpTool.addDocumentVersion(DOCUMENT_ID, "updated body", null, null, null, null, "reworded intro"); + + verify(documentFileService).createNewVersion(eq(DOCUMENT_ID), eq(USERNAME), any()); + verify(documentFileService).updateVersionSummary(eq(DOCUMENT_ID), eq("version-1"), eq("reworded intro"), eq(USERNAME)); + } + + @Test + public void addDocumentVersionNoSourceFails() throws Exception { + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(fileNode(DOCUMENT_ID, "text/plain")); + when(documentFileService.hasEditPermissionOnDocument(DOCUMENT_ID, USER_IDENTITY_ID)).thenReturn(true); + + assertThrows(IllegalArgumentException.class, + () -> documentMcpTool.addDocumentVersion(DOCUMENT_ID, null, null, null, null, null, null)); + } + + @Test + public void addDocumentVersionOnFolderFails() throws Exception { + when(documentFileService.getDocumentById(FOLDER_ID, USERNAME)).thenReturn(folderNode(FOLDER_ID)); + + assertThrows(IllegalArgumentException.class, + () -> documentMcpTool.addDocumentVersion(FOLDER_ID, "body", null, null, null, null, null)); + } + + @Test + public void addDocumentVersionWithoutEditPermissionFails() throws Exception { + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(fileNode(DOCUMENT_ID, "text/plain")); + when(documentFileService.hasEditPermissionOnDocument(DOCUMENT_ID, USER_IDENTITY_ID)).thenReturn(false); + + assertThrows(IllegalAccessException.class, + () -> documentMcpTool.addDocumentVersion(DOCUMENT_ID, "body", null, null, null, null, null)); + } + + @Test + public void createPublicLink() throws Exception { + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(fileNode(DOCUMENT_ID, "application/pdf")); + when(documentFileService.hasEditPermissionOnDocument(DOCUMENT_ID, USER_IDENTITY_ID)).thenReturn(true); + Date expiration = new Date(1893456000000L); + PublicDocumentAccess access = new PublicDocumentAccess(1L, DOCUMENT_ID, null, null, expiration); + access.setDecodedPassword("s3cret"); + when(publicDocumentAccessService.createPublicDocumentAccess(eq(USER_IDENTITY_ID), + eq(DOCUMENT_ID), + eq("s3cret"), + anyLong(), + anyBoolean())).thenReturn(access); + + DocumentPublicLinkModel model = documentMcpTool.createPublicLink(DOCUMENT_ID, "s3cret", expiration.getTime()); + + assertNotNull(model); + assertEquals("/portal/download-document/" + DOCUMENT_ID, model.url()); + assertEquals("s3cret", model.password()); + assertNotNull(model.expirationDate()); + } + + @Test + public void createPublicLinkWithoutEditPermissionFails() throws Exception { + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(fileNode(DOCUMENT_ID, "application/pdf")); + when(documentFileService.hasEditPermissionOnDocument(DOCUMENT_ID, USER_IDENTITY_ID)).thenReturn(false); + + assertThrows(IllegalAccessException.class, () -> documentMcpTool.createPublicLink(DOCUMENT_ID, null, null)); + } + + @Test + public void createDocumentShortcutDefaultsConflictToRename() throws Exception { + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(fileNode(DOCUMENT_ID, "application/pdf")); + when(documentFileService.getDocumentById(FOLDER_ID, USERNAME)).thenReturn(folderNode(FOLDER_ID)); + + documentMcpTool.createDocumentShortcut(DOCUMENT_ID, FOLDER_ID, null); + + verify(documentFileService).createShortcut(eq(DOCUMENT_ID), eq("/documents/Projects"), eq(USERNAME), eq("rename")); + } + + @Test + public void createDocumentShortcutBlankDestinationFails() { + assertThrows(IllegalArgumentException.class, () -> documentMcpTool.createDocumentShortcut(DOCUMENT_ID, " ", null)); + } + + @Test + public void shareDocumentWithUser() throws Exception { + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(fileNode(DOCUMENT_ID, "application/pdf")); + when(documentFileService.hasEditPermissionOnDocument(DOCUMENT_ID, USER_IDENTITY_ID)).thenReturn(true); + org.exoplatform.social.core.identity.model.Identity bob = + new org.exoplatform.social.core.identity.model.Identity("200"); + when(identityManager.getOrCreateUserIdentity("bob")).thenReturn(bob); + + documentMcpTool.shareDocument(DOCUMENT_ID, "bob", null, null); + + verify(documentFileService).shareDocument(eq(DOCUMENT_ID), eq(200L), eq(USER_IDENTITY_ID), eq(true)); + } + + @Test + public void shareDocumentWithSpace() throws Exception { + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(fileNode(DOCUMENT_ID, "application/pdf")); + when(documentFileService.hasEditPermissionOnDocument(DOCUMENT_ID, USER_IDENTITY_ID)).thenReturn(true); + Space space = new Space(); + space.setId(String.valueOf(SPACE_ID)); + space.setPrettyName("engineering"); + when(spaceService.getSpaceById(String.valueOf(SPACE_ID))).thenReturn(space); + org.exoplatform.social.core.identity.model.Identity spaceIdentity = + new org.exoplatform.social.core.identity.model.Identity("300"); + when(identityManager.getOrCreateSpaceIdentity("engineering")).thenReturn(spaceIdentity); + + documentMcpTool.shareDocument(DOCUMENT_ID, null, SPACE_ID, false); + + verify(documentFileService).shareDocument(eq(DOCUMENT_ID), eq(300L), eq(USER_IDENTITY_ID), eq(false)); + } + + @Test + public void shareDocumentRequiresExactlyOneRecipient() throws Exception { + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(fileNode(DOCUMENT_ID, "application/pdf")); + + // neither recipient + assertThrows(IllegalArgumentException.class, () -> documentMcpTool.shareDocument(DOCUMENT_ID, null, null, null)); + // both recipients + assertThrows(IllegalArgumentException.class, () -> documentMcpTool.shareDocument(DOCUMENT_ID, "bob", SPACE_ID, null)); + } + + @Test + public void shareDocumentWithoutEditPermissionFails() throws Exception { + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(fileNode(DOCUMENT_ID, "application/pdf")); + when(documentFileService.hasEditPermissionOnDocument(DOCUMENT_ID, USER_IDENTITY_ID)).thenReturn(false); + + assertThrows(IllegalAccessException.class, () -> documentMcpTool.shareDocument(DOCUMENT_ID, "bob", null, null)); + } + + @Test + public void favoriteDocumentCreatesFavorite() throws Exception { + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(fileNode(DOCUMENT_ID, "application/pdf")); + + documentMcpTool.favoriteDocument(DOCUMENT_ID); + + org.mockito.ArgumentCaptor captor = org.mockito.ArgumentCaptor.forClass(Favorite.class); + verify(favoriteService).createFavorite(captor.capture()); + assertEquals("document", captor.getValue().getObjectType()); + assertEquals(DOCUMENT_ID, captor.getValue().getObjectId()); + assertEquals(USER_IDENTITY_ID, captor.getValue().getUserIdentityId()); + } + + @Test + public void favoriteFolderUsesFolderObjectType() throws Exception { + when(documentFileService.getDocumentById(FOLDER_ID, USERNAME)).thenReturn(folderNode(FOLDER_ID)); + + documentMcpTool.favoriteDocument(FOLDER_ID); + + org.mockito.ArgumentCaptor captor = org.mockito.ArgumentCaptor.forClass(Favorite.class); + verify(favoriteService).createFavorite(captor.capture()); + assertEquals("folder", captor.getValue().getObjectType()); + } + + @Test + public void favoriteDocumentAlreadyFavoriteFails() throws Exception { + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(fileNode(DOCUMENT_ID, "application/pdf")); + doThrow(new ObjectAlreadyExistsException(DOCUMENT_ID)).when(favoriteService).createFavorite(any()); + + assertThrows(IllegalStateException.class, () -> documentMcpTool.favoriteDocument(DOCUMENT_ID)); + } + + @Test + public void unfavoriteDocumentDeletesFavorite() throws Exception { + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(fileNode(DOCUMENT_ID, "application/pdf")); + + documentMcpTool.unfavoriteDocument(DOCUMENT_ID); + + verify(favoriteService).deleteFavorite(any()); + } + + @Test + public void unfavoriteDocumentNotFavoriteFails() throws Exception { + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(fileNode(DOCUMENT_ID, "application/pdf")); + doThrow(new ObjectNotFoundException("not a favorite")).when(favoriteService).deleteFavorite(any()); + + assertThrows(IllegalStateException.class, () -> documentMcpTool.unfavoriteDocument(DOCUMENT_ID)); + } + } From 868ae69f9c3e62f70b75f042a6ee03c412c3f0ff Mon Sep 17 00:00:00 2001 From: Benjamin Mestrallet Date: Tue, 7 Jul 2026 19:52:30 +0100 Subject: [PATCH 13/15] fix: correct integration bugs in Documents MCP tools (conflict values, copy/duplicate return, favorites type, filters, size, share permissions, undo-delete, import conflict) - EXO-88354 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../documents/mcp/DocumentMcpTool.java | 291 ++++++++++++++---- .../main/resources/ai-tool-definitions.json | 10 +- .../documents/mcp/DocumentMcpToolTest.java | 209 ++++++++++--- .../storage/jcr/JCRDocumentFileStorage.java | 32 +- .../jcr/JCRDocumentFileStorageTest.java | 31 ++ 5 files changed, 468 insertions(+), 105 deletions(-) diff --git a/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java b/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java index 783606f20..1df5f2737 100644 --- a/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java +++ b/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java @@ -24,11 +24,15 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.Date; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; +import javax.jcr.RepositoryException; + import org.apache.commons.lang3.StringUtils; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; @@ -55,11 +59,15 @@ import org.exoplatform.documents.model.FileVersion; import org.exoplatform.documents.model.FolderNode; import org.exoplatform.documents.model.FullTreeItem; +import org.exoplatform.documents.model.NodePermission; +import org.exoplatform.documents.model.PermissionEntry; +import org.exoplatform.documents.model.PermissionRole; import org.exoplatform.documents.model.PublicDocumentAccess; import org.exoplatform.documents.service.DocumentFileService; import org.exoplatform.documents.service.PublicDocumentAccessService; import org.exoplatform.portal.config.UserACL; import org.exoplatform.portal.config.UserPortalConfigService; +import org.exoplatform.portal.rest.UserFieldValidator; import org.exoplatform.services.attachments.service.AttachmentService; import org.exoplatform.social.core.identity.model.Identity; import org.exoplatform.social.core.manager.IdentityManager; @@ -104,11 +112,21 @@ public class DocumentMcpTool implements McpToolPlugin { */ public static final String PUBLIC_LINK_URL_FORMAT = "/portal/download-document/%s"; - /** Favorite object type for files, matching {@code DocumentAclPlugin.OBJECT_TYPE}. */ - private static final String FAVORITE_TYPE_DOCUMENT = "document"; + /** + * Same password policy DocumentFileRest applies to public-link passwords + * (min length 9), so a link created here is openable with the given password. + */ + private static final UserFieldValidator PASSWORD_VALIDATOR = new UserFieldValidator("password", false, false, 9, 255); - /** Favorite object type for folders, matching {@code FolderAclPlugin.OBJECT_TYPE}. */ - private static final String FAVORITE_TYPE_FOLDER = "folder"; + /** + * Favorite object type for files. Ground truth: the Documents app reads/writes + * file favorites as {@code metadataService} metadata "favorites" on object type + * {@code "file"} + the JCR node id (see {@code EntityBuilder}, the + * {@code DocumentsFavoriteButton.vue} button and the favorites drawer + * registration). Writing any other type produces a favorite that no surface in + * the app ever shows. + */ + private static final String FAVORITE_TYPE_FILE = "file"; /** * Structured / binary office and PDF formats that cannot be produced from a @@ -218,7 +236,7 @@ public List getDocumentsByFolderId(String folderId, true); return documentItems.stream() .filter(d -> (filesOnly == null || !filesOnly || d instanceof FileNode) - || (foldersOnly == null || !foldersOnly || d instanceof FolderNode)) + && (foldersOnly == null || !foldersOnly || d instanceof FolderNode)) .map(this::toDocumentModel) .toList(); } @@ -341,8 +359,17 @@ public List listDocumentVersions(String documentId) throws public DocumentsSizeModel getDocumentsSize(Long ownerId) throws ObjectNotFoundException, IllegalAccessException { long resolvedOwnerId = ownerId == null || ownerId <= 0 ? getCurrentUserIdentityId() : ownerId; - DocumentsSize size = documentFileService.getDocumentsSizeStat(resolvedOwnerId, getCurrentUserIdentityId()); - return new DocumentsSizeModel(size.getOwnerId(), size.getToSize()); + long userIdentityId = getCurrentUserIdentityId(); + DocumentsSize size = documentFileService.getDocumentsSizeStat(resolvedOwnerId, userIdentityId); + // getDocumentsSizeStat only reads a previously computed analytics "documentsSize" + // stat (the size gadget POSTs addDocumentsSizeStat on demand). When no stat has + // been written yet, or it is stale (not from today), the read yields owner_id 0 / + // size 0, which is misleading. Trigger the on-demand computation so the tool + // returns the real current size for the correct owner. + if (size == null || size.getOwnerId() <= 0 || size.getToSize() <= 0 || !size.isTodaySize()) { + size = documentFileService.addDocumentsSizeStat(resolvedOwnerId, userIdentityId); + } + return new DocumentsSizeModel(resolvedOwnerId, size == null ? 0L : size.getToSize()); } // --------------------------------------------------------------------------- @@ -535,9 +562,9 @@ public void moveDocument(String documentId, documentId, destination.getPath(), getCurrentUserIdentityId(), - StringUtils.isBlank(conflictAction) ? "rename" : conflictAction); + StringUtils.isBlank(conflictAction) ? "keepBoth" : conflictAction); } catch (ObjectAlreadyExistsException e) { - throw new IllegalStateException("A document with the same name already exists in the destination folder. Retry with conflict_action set to 'rename'."); + throw new IllegalStateException("A document with the same name already exists in the destination folder. Retry with conflict_action set to 'keepBoth' (move as a renamed copy) or 'createNewVersion' (replace the existing file's content, keeping its history)."); } } @@ -547,30 +574,66 @@ public DocumentModel copyDocument(String documentId, if (StringUtils.isBlank(destinationFolderId)) { throw new IllegalArgumentException("The 'destinationFolderId' parameter is mandatory. Call get_document_by_id or get_documents_by_folder_id to find the target folder id."); } - AbstractNode copy = documentFileService.copyDocument(documentId, destinationFolderId, getCurrentUserIdentityId()); - return toDocumentModel(copy); + // The storage copyDocument returns the DESTINATION folder node, not the copy, + // so snapshot the destination's file ids first and resolve the newly added + // file afterwards to return the actual copied document. + Set beforeFileIds = currentChildFileIds(destinationFolderId); + AbstractNode copyReturn = documentFileService.copyDocument(documentId, destinationFolderId, getCurrentUserIdentityId()); + DocumentModel copied = resolveAddedChildFile(destinationFolderId, beforeFileIds); + return copied != null ? copied : toDocumentModel(copyReturn); } public DocumentModel duplicateDocument(String documentId, String prefix) throws IllegalAccessException, ObjectNotFoundException { checkDocumentIdParameter(documentId); - AbstractNode duplicate = documentFileService.duplicateDocument(getOwnerId(documentId), - documentId, - prefix, - getCurrentUserIdentityId()); - return toDocumentModel(duplicate); + // The storage duplicateDocument returns the PARENT folder node, not the + // duplicate, so snapshot the parent's file ids first and resolve the newly + // added (suffixed/prefixed) file afterwards to return the actual duplicate. + AbstractNode source = getNode(documentId); + String parentFolderId = source.getParentFolderId(); + Set beforeFileIds = StringUtils.isBlank(parentFolderId) ? Set.of() : currentChildFileIds(parentFolderId); + AbstractNode duplicateReturn = documentFileService.duplicateDocument(getOwnerId(documentId), + documentId, + prefix, + getCurrentUserIdentityId()); + DocumentModel duplicate = StringUtils.isBlank(parentFolderId) ? null + : resolveAddedChildFile(parentFolderId, beforeFileIds); + return duplicate != null ? duplicate : toDocumentModel(duplicateReturn); } public void deleteDocument(String documentId) throws IllegalAccessException, ObjectNotFoundException { AbstractNode document = checkCanAccessDocument(documentId); - // delay = 0: move to trash immediately. favorite = false: the flag only - // triggers removal from the favorites list, which we don't manage here. - documentFileService.deleteDocument(document.getPath(), documentId, false, 0, getCurrentUserIdentityId()); + // delay = 0: move to trash immediately. favorite = true: also drop the file + // from the user's favorites (storage cleanup uses object type "file"), so a + // trashed favorite doesn't linger in the favorites view. + documentFileService.deleteDocument(document.getPath(), documentId, true, 0, getCurrentUserIdentityId()); } - public void undoDeleteDocument(String documentId) { + /** + * Restores a document that was deleted with {@code delete_document}: since that + * tool trashes immediately (delay 0), the delayed-delete cancel path is a no-op, + * so the node is resolved from trash (it keeps its id/uuid after the move) and + * restored to its original location. + */ + public void undoDeleteDocument(String documentId) throws IllegalAccessException, ObjectNotFoundException { checkDocumentIdParameter(documentId); - documentFileService.undoDeleteDocument(documentId, getCurrentUserIdentityId()); + AbstractNode trashedNode; + try { + trashedNode = documentFileService.getDocumentById(documentId, getCurrentUserName()); + } catch (ObjectNotFoundException e) { + throw new IllegalStateException(("Document %s can no longer be found (it may have been permanently deleted from the trash), " + + "so it cannot be restored.").formatted(documentId)); + } + if (trashedNode == null) { + throw new IllegalStateException(("Document %s can no longer be found (it may have been permanently deleted from the trash), " + + "so it cannot be restored.").formatted(documentId)); + } + try { + documentFileService.restoreDocumentFromTrash(trashedNode.getPath()); + } catch (RepositoryException e) { + throw new IllegalStateException(("Could not restore document %s from trash: %s. It may not be in the trash " + + "(only documents deleted via delete_document can be restored this way).").formatted(documentId, e.getMessage())); + } } public DocumentVersionModel restoreDocumentVersion(String versionId) { @@ -586,8 +649,15 @@ public DocumentVersionModel updateVersionSummary(String documentId, String versi if (StringUtils.isBlank(versionId)) { throw new IllegalArgumentException("The 'versionId' parameter is mandatory. Call list_document_versions first to get a version_id."); } - FileVersion version = documentFileService.updateVersionSummary(documentId, versionId, summary, getCurrentUserName()); - return toVersionModel(version); + FileVersion stamped = documentFileService.updateVersionSummary(documentId, versionId, summary, getCurrentUserName()); + // The storage updateVersionSummary returns a near-empty FileVersion (only id + + // summary), so re-read the full version to return number/author/dates/size/etc. + FileVersion full = documentFileService.getFileVersions(documentId, getCurrentUserName()) + .stream() + .filter(v -> StringUtils.equals(v.getId(), versionId)) + .findFirst() + .orElse(stamped); + return toVersionModel(full); } @SneakyThrows @@ -674,6 +744,14 @@ public DocumentPublicLinkModel createPublicLink(String documentId, throw new IllegalAccessException("You are not allowed to edit the document %s, so you cannot create a public link for it." .formatted(documentId)); } + if (StringUtils.isNotBlank(password)) { + // Apply the same password policy as DocumentFileRest (min 9 chars, etc.), so + // a link the LLM creates can actually be opened with the given password. + String error = PASSWORD_VALIDATOR.validate(getCurrentUserLocale(), password); + if (StringUtils.isNotBlank(error)) { + throw new IllegalArgumentException("The public link password is invalid: " + error); + } + } long expiration = expirationDate == null ? 0L : expirationDate.longValue(); PublicDocumentAccess access = publicDocumentAccessService.createPublicDocumentAccess(userIdentityId, documentId, @@ -701,7 +779,8 @@ public DocumentPublicLinkModel createPublicLink(String documentId, * (get it from get_root_folder_for_user / get_root_folder_by_space / * get_documents_by_folder_id) * @param conflictAction behaviour when a node with the same name already exists - * in the destination; defaults to 'rename' + * in the destination; only 'keepBoth' is honoured, defaults to + * 'keepBoth' */ public void createDocumentShortcut(String documentId, String destinationFolderId, @@ -718,17 +797,17 @@ public void createDocumentShortcut(String documentId, documentFileService.createShortcut(documentId, destination.getPath(), getCurrentUserName(), - StringUtils.isBlank(conflictAction) ? "rename" : conflictAction); + StringUtils.isBlank(conflictAction) ? "keepBoth" : conflictAction); } catch (ObjectAlreadyExistsException e) { - throw new IllegalStateException("A node with the same name already exists in the destination folder. Retry with conflict_action set to 'rename'."); + throw new IllegalStateException("A node with the same name already exists in the destination folder. Retry with conflict_action set to 'keepBoth' to create the shortcut under a renamed entry."); } } /** - * Shares a document with another user or with a space: the recipient gets the - * file in their "Shared" folder with the document's own permissions. Provide - * exactly one of username or spaceId. Requires edit - * permission on the document. + * Shares a document with another user or with a space: the recipient is GRANTED + * read access to the file and gets it in their "Shared" folder. Provide exactly + * one of username or spaceId. Requires edit permission + * on the document. * * @param documentId the id of the file to share (get it from the read tools) * @param username the username to share the document with (mutually exclusive @@ -741,7 +820,7 @@ public void shareDocument(String documentId, String username, Long spaceId, Boolean notify) throws IllegalAccessException, ObjectNotFoundException { - checkCanAccessDocument(documentId); + AbstractNode node = checkCanAccessDocument(documentId); boolean hasUser = StringUtils.isNotBlank(username); boolean hasSpace = spaceId != null && spaceId > 0; if (hasUser == hasSpace) { @@ -750,23 +829,84 @@ public void shareDocument(String documentId, if (!documentFileService.hasEditPermissionOnDocument(documentId, getCurrentUserIdentityId())) { throw new IllegalAccessException("You are not allowed to edit the document %s, so you cannot share it.".formatted(documentId)); } - long destId; + Identity destIdentity; if (hasUser) { - Identity userIdentity = identityManager.getOrCreateUserIdentity(username); - if (userIdentity == null) { + destIdentity = identityManager.getOrCreateUserIdentity(username); + if (destIdentity == null) { throw new ObjectNotFoundException("No user found with username '%s'. Use search_users to find the exact username." .formatted(username)); } - destId = userIdentity.getIdentityId(); } else { Space space = spaceService.getSpaceById(String.valueOf(spaceId)); if (space == null) { throw new ObjectNotFoundException("Space with id %s not found. Use get_my_spaces to check accessible spaces." .formatted(spaceId)); } - destId = identityManager.getOrCreateSpaceIdentity(space.getPrettyName()).getIdentityId(); + destIdentity = identityManager.getOrCreateSpaceIdentity(space.getPrettyName()); + } + long destId = destIdentity.getIdentityId(); + // Route through updatePermissions like the REST does: shareDocument alone only + // copies the recipient's EXISTING permissions onto the symlink, so a recipient + // without prior access still can't open the file. updatePermissions GRANTS the + // recipient read access on the node (permissions + toShare) and then shares + // (creates the symlink). The current collaborators are preserved so they are + // not unshared by the storage's replace-then-unshare logic. + List permissions = preserveExistingCollaborators(node.getAcl()); + permissions.add(new PermissionEntry(destIdentity, "read", PermissionRole.ALL.name())); + Map toShare = new HashMap<>(); + toShare.put(destId, "read"); + Map toNotify = new HashMap<>(); + if (notify == null || notify.booleanValue()) { + toNotify.put(destId, "read"); + } + NodePermission nodePermission = node.getAcl() == null ? new NodePermission(true, false, false, false, permissions, toShare, toNotify, null) + : new NodePermission(node.getAcl().isCanAccess(), + node.getAcl().isCanEdit(), + node.getAcl().isCanDelete(), + node.getAcl().isPublic(), + permissions, + toShare, + toNotify, + null); + documentFileService.updatePermissions(documentId, nodePermission, getCurrentUserIdentityId()); + } + + /** + * Rebuilds the document's current collaborator list (as read/edit + * {@link PermissionEntry}) from its resolved ACL, so a share can be added + * through {@code updatePermissions} without the storage's replace-then-unshare + * logic dropping the users/spaces that already had access. Mirrors the + * read/edit derivation the REST layer uses ({@code EntityBuilder}). + */ + private List preserveExistingCollaborators(NodePermission acl) { + List result = new java.util.ArrayList<>(); + if (acl == null || acl.getPermissions() == null) { + return result; + } + // Collapse the per-permission ACL entries (each identity appears once per raw + // JCR permission) into one entry per identity+role, marking it "edit" when any + // of its raw permissions is a write permission, else "read". + Map identityByKey = new java.util.LinkedHashMap<>(); + Map editByKey = new HashMap<>(); + for (PermissionEntry entry : acl.getPermissions()) { + Identity identity = entry.getIdentity(); + if (identity == null) { + continue; + } + String key = identity.getProviderId() + "|" + identity.getRemoteId() + "|" + entry.getRole(); + identityByKey.putIfAbsent(key, identity); + editByKey.merge(key, isEditAclPermission(entry.getPermission()), Boolean::logicalOr); } - documentFileService.shareDocument(documentId, destId, getCurrentUserIdentityId(), notify == null || notify.booleanValue()); + identityByKey.forEach((key, identity) -> { + String role = key.substring(key.lastIndexOf('|') + 1); + result.add(new PermissionEntry(identity, Boolean.TRUE.equals(editByKey.get(key)) ? "edit" : "read", role)); + }); + return result; + } + + private static boolean isEditAclPermission(String permission) { + return permission != null + && (permission.contains("add_node") || permission.contains("set_property") || permission.contains("remove")); } /** @@ -800,8 +940,13 @@ public void unfavoriteDocument(String documentId) throws IllegalAccessException, private Favorite toFavorite(String documentId) throws IllegalAccessException, ObjectNotFoundException { AbstractNode node = checkCanAccessDocument(documentId); - String objectType = node instanceof FileNode ? FAVORITE_TYPE_DOCUMENT : FAVORITE_TYPE_FOLDER; - return new Favorite(objectType, documentId, null, getCurrentUserIdentityId()); + if (!(node instanceof FileNode)) { + // Folders have no favorite surface in the Documents app (the favorite + // button and drawer only exist for files, object type "file"). Writing a + // "folder" favorite would silently never appear anywhere, so reject it. + throw new IllegalArgumentException("Only files can be favorited; folders have no favorites view."); + } + return new Favorite(FAVORITE_TYPE_FILE, documentId, null, getCurrentUserIdentityId()); } /** @@ -1053,6 +1198,10 @@ private DocumentModel importContent(String parentFolderId, String entryName = sanitizeFileName(fileName); String ownerId = String.valueOf(getOwnerId(parentFolderId)); long userIdentityId = getCurrentUserIdentityId(); + // Snapshot the destination file ids before the import so the created node can + // be identified even when a same-name file already exists and the import + // creates a renamed ("duplicate") copy with a different, suffixed name. + Set beforeFileIds = currentChildFileIds(parentFolderId); String uploadId = null; try { uploadId = UploadToolUtils.materialize(uploadService, @@ -1060,13 +1209,15 @@ private DocumentModel importContent(String parentFolderId, entryName + ".zip", "application/zip"); // folderPath stays null: parentFolderId already identifies the JCR node - // (same rule as createFolder). conflict "rename" keeps existing documents - // untouched on a name clash. + // (same rule as createFolder). conflict "duplicate": on a name clash the + // import creates a renamed (keep-both) copy. NOT "rename" — the async import + // thread only understands "updateAll"/"duplicate", so "rename" would fall + // through to ignoredFiles and silently create nothing. documentFileService.importFiles(ownerId, parentFolderId, null, uploadId, - "rename", + "duplicate", getCurrentUserAclIdentity(), userIdentityId); // On success the asynchronous import owns the upload resource and releases @@ -1081,26 +1232,22 @@ private DocumentModel importContent(String parentFolderId, UploadToolUtils.release(uploadService, uploadId); } } - return findImportedDocument(parentFolderId, entryName); + return findImportedDocument(parentFolderId, entryName, beforeFileIds); } /** * The import runs on a background thread, so the created file is polled for in - * the destination folder until it appears (or a short timeout elapses). + * the destination folder until it appears (or a short timeout elapses). The + * newly created node is identified as the file whose id was not present before + * the import, so it resolves the just-created (possibly suffixed) file rather + * than a pre-existing same-name document. */ private DocumentModel findImportedDocument(String parentFolderId, - String fileName) throws ObjectNotFoundException, IllegalAccessException { - long userIdentityId = getCurrentUserIdentityId(); - DocumentFolderFilter filter = new DocumentFolderFilter(parentFolderId, null, null, null); + String fileName, + Set beforeFileIds) throws ObjectNotFoundException, + IllegalAccessException { for (int attempt = 0; attempt < IMPORT_POLL_MAX_ATTEMPTS; attempt++) { - List children = documentFileService.getFolderChildNodes(filter, 0, IMPORT_POLL_PAGE_SIZE, userIdentityId); - DocumentFileModel match = children.stream() - .filter(FileNode.class::isInstance) - .map(FileNode.class::cast) - .filter(file -> StringUtils.equalsIgnoreCase(file.getName(), fileName)) - .reduce((first, second) -> second) - .map(this::toDocumentFileModel) - .orElse(null); + DocumentModel match = resolveAddedChildFile(parentFolderId, beforeFileIds); if (match != null) { return match; } @@ -1112,6 +1259,40 @@ private DocumentModel findImportedDocument(String parentFolderId, """.formatted(fileName, parentFolderId)); } + /** + * Lists the file ids currently under a folder, used to snapshot a destination + * before an operation that adds a file (import / copy / duplicate) so the newly + * created node can be identified afterwards. + */ + private Set currentChildFileIds(String folderId) throws ObjectNotFoundException, IllegalAccessException { + DocumentFolderFilter filter = new DocumentFolderFilter(folderId, null, null, null); + return documentFileService.getFolderChildNodes(filter, 0, IMPORT_POLL_PAGE_SIZE, getCurrentUserIdentityId()) + .stream() + .filter(FileNode.class::isInstance) + .map(AbstractNode::getId) + .collect(java.util.stream.Collectors.toSet()); + } + + /** + * Resolves the file that was just added to a folder as the child file whose id + * is not in the pre-operation snapshot; returns {@code null} if none is found + * yet. + */ + private DocumentModel resolveAddedChildFile(String folderId, + Set beforeFileIds) throws ObjectNotFoundException, + IllegalAccessException { + DocumentFolderFilter filter = new DocumentFolderFilter(folderId, null, null, null); + List children = documentFileService.getFolderChildNodes(filter, 0, IMPORT_POLL_PAGE_SIZE, + getCurrentUserIdentityId()); + return children.stream() + .filter(FileNode.class::isInstance) + .map(FileNode.class::cast) + .filter(file -> !beforeFileIds.contains(file.getId())) + .reduce((first, second) -> second) + .map(this::toDocumentFileModel) + .orElse(null); + } + private static byte[] zipSingleEntry(String entryName, byte[] content) { ByteArrayOutputStream output = new ByteArrayOutputStream(); try (ZipOutputStream zip = new ZipOutputStream(output)) { diff --git a/documents-services/src/main/resources/ai-tool-definitions.json b/documents-services/src/main/resources/ai-tool-definitions.json index 6bb0d078b..486e56e51 100644 --- a/documents-services/src/main/resources/ai-tool-definitions.json +++ b/documents-services/src/main/resources/ai-tool-definitions.json @@ -327,7 +327,7 @@ { "name": "list_document_versions", "title": "List DMS file versions", - "description": "Lists the versions of a versioned file, newest last. Call get_document_by_id or get_documents_by_folder_id first to get a document_id.", + "description": "Lists the versions of a versioned file, newest first. Call get_document_by_id or get_documents_by_folder_id first to get a document_id.", "input_schema": { "type": "object", "properties": { @@ -520,7 +520,8 @@ }, "conflict_action": { "type": "string", - "description": "Behaviour when a document with the same name exists in the destination. Defaults to 'rename'." + "enum": ["keepBoth", "createNewVersion"], + "description": "Behaviour when a document with the same name exists in the destination: 'keepBoth' moves it as a renamed copy, 'createNewVersion' replaces the existing file's content (keeping its version history). Defaults to 'keepBoth'." } }, "required": [ @@ -825,7 +826,8 @@ }, "conflict_action": { "type": "string", - "description": "Behaviour when a node with the same name already exists in the destination. Defaults to 'rename'." + "enum": ["keepBoth"], + "description": "Behaviour when a node with the same name already exists in the destination: only 'keepBoth' is supported (creates the shortcut under a renamed entry). Defaults to 'keepBoth'." } }, "required": [ @@ -844,7 +846,7 @@ { "name": "share_document", "title": "Share a DMS file with a user or space", - "description": "Shares a document with another user or with a space: the recipient gets the file in their 'Shared' folder with the document's own permissions. Provide exactly one recipient: either username or space_id. Requires edit permission on the document. Call get_document_by_id first to get the document_id, search_users for a username, or get_my_spaces for a space_id.", + "description": "Shares a document with another user or with a space: the recipient is granted read access and gets the file in their 'Shared' folder. Provide exactly one recipient: either username or space_id. Requires edit permission on the document. Call get_document_by_id first to get the document_id, search_users for a username, or get_my_spaces for a space_id.", "input_schema": { "type": "object", "properties": { diff --git a/documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java b/documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java index 1a1118232..a5d83a9c1 100644 --- a/documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java +++ b/documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java @@ -20,17 +20,22 @@ import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertFalse; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.util.Collections; import java.util.Date; import java.util.List; import java.util.Locale; @@ -56,6 +61,7 @@ import org.exoplatform.documents.model.FileVersion; import org.exoplatform.documents.model.FolderNode; import org.exoplatform.documents.model.FullTreeItem; +import org.exoplatform.documents.model.NodePermission; import org.exoplatform.documents.model.PublicDocumentAccess; import org.exoplatform.documents.service.DocumentFileService; import org.exoplatform.documents.service.PublicDocumentAccessService; @@ -242,6 +248,32 @@ public void getDocumentsByFolderId() throws Exception { assertEquals(2, models.size()); } + @Test + public void getDocumentsByFolderIdFilesOnlyExcludesFolders() throws Exception { + List items = List.of(fileNode(DOCUMENT_ID, "application/pdf"), folderNode(FOLDER_ID)); + Mockito.>when(documentFileService.getDocumentItems(any(), any(), anyInt(), anyInt(), anyLong(), anyBoolean())) + .thenReturn(items); + + List models = documentMcpTool.getDocumentsByFolderId(FOLDER_ID, Boolean.TRUE, null, null, null); + + // files_only=true must keep only the file, dropping the folder. + assertEquals(1, models.size()); + assertEquals(DOCUMENT_ID, models.get(0).getId()); + } + + @Test + public void getDocumentsByFolderIdFoldersOnlyExcludesFiles() throws Exception { + List items = List.of(fileNode(DOCUMENT_ID, "application/pdf"), folderNode(FOLDER_ID)); + Mockito.>when(documentFileService.getDocumentItems(any(), any(), anyInt(), anyInt(), anyLong(), anyBoolean())) + .thenReturn(items); + + List models = documentMcpTool.getDocumentsByFolderId(FOLDER_ID, null, Boolean.TRUE, null, null); + + // folders_only=true must keep only the folder, dropping the file. + assertEquals(1, models.size()); + assertEquals(FOLDER_ID, models.get(0).getId()); + } + @Test public void searchDocuments() throws Exception { when(documentFileService.search(any(org.exoplatform.documents.model.DocumentTimelineFilter.class), @@ -393,6 +425,25 @@ public void getDocumentsSizeDefaultsToCurrentUser() throws Exception { assertNotNull(model); assertEquals(USER_IDENTITY_ID, model.ownerId()); assertEquals(4096L, model.sizeInBytes()); + // Fresh, non-zero, today's stat -> no on-demand compute needed. + verify(documentFileService, never()).addDocumentsSizeStat(anyLong(), anyLong()); + } + + @Test + public void getDocumentsSizeComputesWhenStatMissing() throws Exception { + // No stat written yet -> getDocumentsSizeStat returns an empty (owner 0, size 0) value. + when(documentFileService.getDocumentsSizeStat(USER_IDENTITY_ID, USER_IDENTITY_ID)) + .thenReturn(new DocumentsSize(0L, 0L, 0L, 0L, 0L, false, 0L)); + when(documentFileService.addDocumentsSizeStat(USER_IDENTITY_ID, USER_IDENTITY_ID)) + .thenReturn(new DocumentsSize(USER_IDENTITY_ID, 8192L, 0L, 0L, 0L, true, 0L)); + + DocumentsSizeModel model = documentMcpTool.getDocumentsSize(null); + + // The tool must trigger the compute path and return the real size for the correct owner, + // never the misleading owner_id 0 / size 0. + verify(documentFileService).addDocumentsSizeStat(USER_IDENTITY_ID, USER_IDENTITY_ID); + assertEquals(USER_IDENTITY_ID, model.ownerId()); + assertEquals(8192L, model.sizeInBytes()); } @Test @@ -452,7 +503,9 @@ private FileNode fileNodeNamed(String id, String name) { public void createDocumentTextExtensionSucceeds() throws Exception { when(documentFileService.getDocumentById(FOLDER_ID, USERNAME)).thenReturn(folderNode(FOLDER_ID)); when(documentFileService.getRootFolderOwnerId(FOLDER_ID)).thenReturn(OWNER_ID); + // First call = pre-import snapshot (empty), second call = after import (the new file). when(documentFileService.getFolderChildNodes(any(), anyInt(), anyInt(), anyLong())) + .thenReturn(Collections.emptyList()) .thenReturn(List.of(fileNodeNamed("doc-txt", "notes.txt"))); // The name is used as-is (it already carries a .txt extension). @@ -460,12 +513,14 @@ public void createDocumentTextExtensionSucceeds() throws Exception { assertNotNull(model); assertEquals("doc-txt", model.getId()); - // folderPath must be null (parent resolved from the folder id) and conflict "rename". + // folderPath must be null (parent resolved from the folder id) and conflict + // "duplicate" (NOT "rename": the async import thread only understands + // "updateAll"/"duplicate", so "rename" silently creates nothing on a clash). verify(documentFileService).importFiles(eq(String.valueOf(OWNER_ID)), eq(FOLDER_ID), isNull(), any(), - eq("rename"), + eq("duplicate"), eq(currentIdentity), eq(USER_IDENTITY_ID)); } @@ -519,6 +574,7 @@ public void uploadDocumentFromBase64() throws Exception { when(documentFileService.getDocumentById(FOLDER_ID, USERNAME)).thenReturn(folderNode(FOLDER_ID)); when(documentFileService.getRootFolderOwnerId(FOLDER_ID)).thenReturn(OWNER_ID); when(documentFileService.getFolderChildNodes(any(), anyInt(), anyInt(), anyLong())) + .thenReturn(Collections.emptyList()) .thenReturn(List.of(fileNodeNamed("upl-1", "hello.txt"))); String base64 = java.util.Base64.getEncoder().encodeToString("hello".getBytes()); @@ -530,7 +586,7 @@ public void uploadDocumentFromBase64() throws Exception { eq(FOLDER_ID), isNull(), any(), - eq("rename"), + eq("duplicate"), eq(currentIdentity), eq(USER_IDENTITY_ID)); } @@ -540,6 +596,7 @@ public void uploadDocumentFromChatAttachment() throws Exception { when(documentFileService.getDocumentById(FOLDER_ID, USERNAME)).thenReturn(folderNode(FOLDER_ID)); when(documentFileService.getRootFolderOwnerId(FOLDER_ID)).thenReturn(OWNER_ID); when(documentFileService.getFolderChildNodes(any(), anyInt(), anyInt(), anyLong())) + .thenReturn(Collections.emptyList()) .thenReturn(List.of(fileNodeNamed("upl-att", "screenshot.png"))); // The client injects a platform attachment reference; the tool resolves its // bytes as the current user (ACL enforced) via the social AttachmentService. @@ -562,7 +619,7 @@ public void uploadDocumentFromChatAttachment() throws Exception { eq(FOLDER_ID), isNull(), any(), - eq("rename"), + eq("duplicate"), eq(currentIdentity), eq(USER_IDENTITY_ID)); } @@ -605,17 +662,19 @@ public void renameDocument() throws Exception { } @Test - public void moveDocumentDefaultsConflictActionToRename() throws Exception { + public void moveDocumentDefaultsConflictActionToKeepBoth() throws Exception { when(documentFileService.getDocumentById(FOLDER_ID, USERNAME)).thenReturn(folderNode(FOLDER_ID)); when(documentFileService.getRootFolderOwnerId(DOCUMENT_ID)).thenReturn(OWNER_ID); documentMcpTool.moveDocument(DOCUMENT_ID, FOLDER_ID, null); + // "keepBoth" is the only rename-style conflict value the storage honours; + // the old default "rename" was ignored (fell through to ObjectAlreadyExists). verify(documentFileService).moveDocument(eq(OWNER_ID), eq(DOCUMENT_ID), eq("/documents/Projects"), eq(USER_IDENTITY_ID), - eq("rename")); + eq("keepBoth")); } @Test @@ -624,25 +683,41 @@ public void moveDocumentBlankDestinationFails() { } @Test - public void copyDocument() throws Exception { - when(documentFileService.copyDocument(DOCUMENT_ID, FOLDER_ID, USER_IDENTITY_ID)).thenReturn(fileNode("copy-1", "application/pdf")); + public void copyDocumentReturnsResolvedCopyNotDestinationFolder() throws Exception { + // The storage copyDocument returns the DESTINATION FOLDER node (not the copy); + // the tool must re-resolve the newly added file in the destination folder. + when(documentFileService.copyDocument(DOCUMENT_ID, FOLDER_ID, USER_IDENTITY_ID)).thenReturn(folderNode(FOLDER_ID)); + when(documentFileService.getFolderChildNodes(any(), anyInt(), anyInt(), anyLong())) + .thenReturn(Collections.emptyList()) + .thenReturn(List.of(fileNodeNamed("copy-1", "report.pdf"))); DocumentModel model = documentMcpTool.copyDocument(DOCUMENT_ID, FOLDER_ID); assertNotNull(model); + // Must be the resolved copied file, NOT the destination folder id the storage returned. assertEquals("copy-1", model.getId()); + assertFalse(FOLDER_ID.equals(model.getId())); } @Test - public void duplicateDocument() throws Exception { + public void duplicateDocumentReturnsResolvedDuplicateNotParentFolder() throws Exception { + // The storage duplicateDocument returns the PARENT FOLDER node (not the + // duplicate); the tool must re-resolve the newly added file in the parent. + FileNode source = fileNode(DOCUMENT_ID, "application/pdf"); + source.setParentFolderId(FOLDER_ID); + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(source); when(documentFileService.getRootFolderOwnerId(DOCUMENT_ID)).thenReturn(OWNER_ID); - when(documentFileService.duplicateDocument(OWNER_ID, DOCUMENT_ID, "Copy of", USER_IDENTITY_ID)).thenReturn(fileNode("dup-1", - "application/pdf")); + when(documentFileService.duplicateDocument(OWNER_ID, DOCUMENT_ID, "Copy of", USER_IDENTITY_ID)).thenReturn(folderNode(FOLDER_ID)); + when(documentFileService.getFolderChildNodes(any(), anyInt(), anyInt(), anyLong())) + .thenReturn(List.of(source)) + .thenReturn(List.of(source, fileNodeNamed("dup-1", "report(1).pdf"))); DocumentModel model = documentMcpTool.duplicateDocument(DOCUMENT_ID, "Copy of"); assertNotNull(model); + // Must be the resolved duplicate (the newly added file), NOT the parent folder. assertEquals("dup-1", model.getId()); + assertFalse(FOLDER_ID.equals(model.getId())); } @Test @@ -651,9 +726,11 @@ public void deleteDocumentMovesToTrashImmediately() throws Exception { documentMcpTool.deleteDocument(DOCUMENT_ID); + // favorite=true so a favorited file is also dropped from favorites on delete + // (the storage cleanup uses object type "file"). verify(documentFileService).deleteDocument(eq("/documents/report.pdf"), eq(DOCUMENT_ID), - eq(false), + eq(true), eq(0L), eq(USER_IDENTITY_ID)); } @@ -666,10 +743,26 @@ public void deleteDocumentUnknownFails() throws Exception { } @Test - public void undoDeleteDocument() { + public void undoDeleteDocumentRestoresFromTrash() throws Exception { + // After an immediate delete the node lives in trash but keeps its id; undo must + // actually restore it from trash (the delayed-delete cancel path is a no-op). + FileNode trashed = fileNode(DOCUMENT_ID, "application/pdf"); + trashed.setPath("/Trash/report.pdf"); + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(trashed); + documentMcpTool.undoDeleteDocument(DOCUMENT_ID); - verify(documentFileService).undoDeleteDocument(eq(DOCUMENT_ID), eq(USER_IDENTITY_ID)); + verify(documentFileService).restoreDocumentFromTrash(eq("/Trash/report.pdf")); + // It must NOT rely on the no-op delayed-delete cancel. + verify(documentFileService, never()).undoDeleteDocument(anyString(), anyLong()); + } + + @Test + public void undoDeleteDocumentNotFoundFails() throws Exception { + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenThrow(new ObjectNotFoundException("gone")); + + // Never silently succeed: a document no longer in trash must surface a clear error. + assertThrows(IllegalStateException.class, () -> documentMcpTool.undoDeleteDocument(DOCUMENT_ID)); } @Test @@ -688,15 +781,25 @@ public void restoreDocumentVersionBlankFails() { } @Test - public void updateVersionSummary() { - FileVersion updated = fileVersion(); - updated.setSummary("new summary"); - when(documentFileService.updateVersionSummary(DOCUMENT_ID, "version-1", "new summary", USERNAME)).thenReturn(updated); + public void updateVersionSummaryRereadsFullVersion() { + // Storage updateVersionSummary returns a near-empty FileVersion (only id + summary); + // the tool must re-read the full version so number/author/dates/size are populated. + FileVersion stamped = new FileVersion(); + stamped.setId("version-1"); + stamped.setSummary("new summary"); + when(documentFileService.updateVersionSummary(DOCUMENT_ID, "version-1", "new summary", USERNAME)).thenReturn(stamped); + FileVersion full = fileVersion(); + full.setSummary("new summary"); + when(documentFileService.getFileVersions(DOCUMENT_ID, USERNAME)).thenReturn(List.of(full)); DocumentVersionModel model = documentMcpTool.updateVersionSummary(DOCUMENT_ID, "version-1", "new summary"); assertNotNull(model); assertEquals("new summary", model.summary()); + // Fields that only the full re-read carries: + assertEquals(2, model.versionNumber()); + assertEquals("Test User", model.authorFullName()); + assertTrue(model.current()); } @Test @@ -775,21 +878,31 @@ public void createPublicLink() throws Exception { when(documentFileService.hasEditPermissionOnDocument(DOCUMENT_ID, USER_IDENTITY_ID)).thenReturn(true); Date expiration = new Date(1893456000000L); PublicDocumentAccess access = new PublicDocumentAccess(1L, DOCUMENT_ID, null, null, expiration); - access.setDecodedPassword("s3cret"); + access.setDecodedPassword("s3cretPass"); when(publicDocumentAccessService.createPublicDocumentAccess(eq(USER_IDENTITY_ID), eq(DOCUMENT_ID), - eq("s3cret"), + eq("s3cretPass"), anyLong(), anyBoolean())).thenReturn(access); - DocumentPublicLinkModel model = documentMcpTool.createPublicLink(DOCUMENT_ID, "s3cret", expiration.getTime()); + DocumentPublicLinkModel model = documentMcpTool.createPublicLink(DOCUMENT_ID, "s3cretPass", expiration.getTime()); assertNotNull(model); assertEquals("/portal/download-document/" + DOCUMENT_ID, model.url()); - assertEquals("s3cret", model.password()); + assertEquals("s3cretPass", model.password()); assertNotNull(model.expirationDate()); } + @Test + public void createPublicLinkRejectsInvalidPassword() throws Exception { + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(fileNode(DOCUMENT_ID, "application/pdf")); + when(documentFileService.hasEditPermissionOnDocument(DOCUMENT_ID, USER_IDENTITY_ID)).thenReturn(true); + + // Too short for the same password policy DocumentFileRest applies (min 9 chars). + assertThrows(IllegalArgumentException.class, () -> documentMcpTool.createPublicLink(DOCUMENT_ID, "short", null)); + verify(publicDocumentAccessService, never()).createPublicDocumentAccess(anyLong(), anyString(), anyString(), anyLong(), anyBoolean()); + } + @Test public void createPublicLinkWithoutEditPermissionFails() throws Exception { when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(fileNode(DOCUMENT_ID, "application/pdf")); @@ -799,13 +912,15 @@ public void createPublicLinkWithoutEditPermissionFails() throws Exception { } @Test - public void createDocumentShortcutDefaultsConflictToRename() throws Exception { + public void createDocumentShortcutDefaultsConflictToKeepBoth() throws Exception { when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(fileNode(DOCUMENT_ID, "application/pdf")); when(documentFileService.getDocumentById(FOLDER_ID, USERNAME)).thenReturn(folderNode(FOLDER_ID)); documentMcpTool.createDocumentShortcut(DOCUMENT_ID, FOLDER_ID, null); - verify(documentFileService).createShortcut(eq(DOCUMENT_ID), eq("/documents/Projects"), eq(USERNAME), eq("rename")); + // "keepBoth" is the only value the storage handleShortcutDocConflict honours; + // the old default "rename" fell through to ObjectAlreadyExists on a clash. + verify(documentFileService).createShortcut(eq(DOCUMENT_ID), eq("/documents/Projects"), eq(USERNAME), eq("keepBoth")); } @Test @@ -823,7 +938,18 @@ public void shareDocumentWithUser() throws Exception { documentMcpTool.shareDocument(DOCUMENT_ID, "bob", null, null); - verify(documentFileService).shareDocument(eq(DOCUMENT_ID), eq(200L), eq(USER_IDENTITY_ID), eq(true)); + // Must route through updatePermissions (grant + share), not the bare shareDocument + // that only copies the recipient's pre-existing permissions onto the symlink. + verify(documentFileService).updatePermissions(eq(DOCUMENT_ID), + argThat((NodePermission np) -> np.getToShare().containsKey(200L) + && np.getToNotify().containsKey(200L) + && np.getPermissions() + .stream() + .anyMatch(p -> p.getIdentity() + .getIdentityId() == 200L + && "read".equals(p.getPermission()))), + eq(USER_IDENTITY_ID)); + verify(documentFileService, never()).shareDocument(anyString(), anyLong(), anyLong(), anyBoolean()); } @Test @@ -840,7 +966,11 @@ public void shareDocumentWithSpace() throws Exception { documentMcpTool.shareDocument(DOCUMENT_ID, null, SPACE_ID, false); - verify(documentFileService).shareDocument(eq(DOCUMENT_ID), eq(300L), eq(USER_IDENTITY_ID), eq(false)); + // notify=false -> the recipient is in toShare (granted access) but not in toNotify. + verify(documentFileService).updatePermissions(eq(DOCUMENT_ID), + argThat((NodePermission np) -> np.getToShare().containsKey(300L) + && np.getToNotify().isEmpty()), + eq(USER_IDENTITY_ID)); } @Test @@ -867,22 +997,29 @@ public void favoriteDocumentCreatesFavorite() throws Exception { documentMcpTool.favoriteDocument(DOCUMENT_ID); - org.mockito.ArgumentCaptor captor = org.mockito.ArgumentCaptor.forClass(Favorite.class); - verify(favoriteService).createFavorite(captor.capture()); - assertEquals("document", captor.getValue().getObjectType()); - assertEquals(DOCUMENT_ID, captor.getValue().getObjectId()); - assertEquals(USER_IDENTITY_ID, captor.getValue().getUserIdentityId()); + // Ground truth: file favorites are stored under object type "file" (the JCR node + // id), which is what the app's favorite button / drawer read. Not "document". + verify(favoriteService).createFavorite(argThat(f -> "file".equals(f.getObjectType()) + && DOCUMENT_ID.equals(f.getObjectId()) + && f.getUserIdentityId() == USER_IDENTITY_ID)); } @Test - public void favoriteFolderUsesFolderObjectType() throws Exception { + public void favoriteFolderIsRejected() throws Exception { when(documentFileService.getDocumentById(FOLDER_ID, USERNAME)).thenReturn(folderNode(FOLDER_ID)); - documentMcpTool.favoriteDocument(FOLDER_ID); + // Folders have no favorite surface in the app; favoriting one must throw rather + // than writing a "folder" favorite that nothing ever shows. + assertThrows(IllegalArgumentException.class, () -> documentMcpTool.favoriteDocument(FOLDER_ID)); + verify(favoriteService, never()).createFavorite(any()); + } + + @Test + public void unfavoriteFolderIsRejected() throws Exception { + when(documentFileService.getDocumentById(FOLDER_ID, USERNAME)).thenReturn(folderNode(FOLDER_ID)); - org.mockito.ArgumentCaptor captor = org.mockito.ArgumentCaptor.forClass(Favorite.class); - verify(favoriteService).createFavorite(captor.capture()); - assertEquals("folder", captor.getValue().getObjectType()); + assertThrows(IllegalArgumentException.class, () -> documentMcpTool.unfavoriteDocument(FOLDER_ID)); + verify(favoriteService, never()).deleteFavorite(any()); } @Test diff --git a/documents-storage-jcr/src/main/java/org/exoplatform/documents/storage/jcr/JCRDocumentFileStorage.java b/documents-storage-jcr/src/main/java/org/exoplatform/documents/storage/jcr/JCRDocumentFileStorage.java index 484e15957..50d8dc003 100644 --- a/documents-storage-jcr/src/main/java/org/exoplatform/documents/storage/jcr/JCRDocumentFileStorage.java +++ b/documents-storage-jcr/src/main/java/org/exoplatform/documents/storage/jcr/JCRDocumentFileStorage.java @@ -2643,18 +2643,30 @@ public FileVersion createNewVersion(String nodeId, String aclIdentity, InputStre ManageableRepository manageableRepository = repositoryService.getCurrentRepository(); Session session = getUserSessionProvider(repositoryService, identity).getSession(COLLABORATION, manageableRepository); Node node = session.getNodeByUUID(nodeId); - if (node.isNodeType(NodeTypeConstants.MIX_VERSIONABLE) && node.getNode(NodeTypeConstants.JCR_CONTENT) != null) { - Node contentNode = node.getNode(NodeTypeConstants.JCR_CONTENT); - if (contentNode.hasProperty(NodeTypeConstants.JCR_DATA)) { - contentNode.setProperty(NodeTypeConstants.JCR_DATA, newContent); - contentNode.setProperty(NodeTypeConstants.JCR_LAST_MODIFIED, Calendar.getInstance()); + if (node.hasNode(NodeTypeConstants.JCR_CONTENT)) { + // Make the file versionable if it isn't yet: otherwise the whole write was + // silently skipped and no version was ever created (mirror + // ActionThread.createFile which adds mix:versionable on file creation). + if (!node.isNodeType(NodeTypeConstants.MIX_VERSIONABLE) && node.canAddMixin(NodeTypeConstants.MIX_VERSIONABLE)) { + node.addMixin(NodeTypeConstants.MIX_VERSIONABLE); + node.save(); } - if (node.isNodeType(NodeTypeConstants.EXO_MODIFY)) { - node.setProperty(NodeTypeConstants.EXO_DATE_MODIFIED, Calendar.getInstance()); - node.setProperty(NodeTypeConstants.EXO_LAST_MODIFIED_DATE, Calendar.getInstance()); + if (node.isNodeType(NodeTypeConstants.MIX_VERSIONABLE)) { + if (!node.isCheckedOut()) { + node.checkout(); + } + Node contentNode = node.getNode(NodeTypeConstants.JCR_CONTENT); + if (contentNode.hasProperty(NodeTypeConstants.JCR_DATA)) { + contentNode.setProperty(NodeTypeConstants.JCR_DATA, newContent); + contentNode.setProperty(NodeTypeConstants.JCR_LAST_MODIFIED, Calendar.getInstance()); + } + if (node.isNodeType(NodeTypeConstants.EXO_MODIFY)) { + node.setProperty(NodeTypeConstants.EXO_DATE_MODIFIED, Calendar.getInstance()); + node.setProperty(NodeTypeConstants.EXO_LAST_MODIFIED_DATE, Calendar.getInstance()); + } + node.save(); + VersionHistoryUtils.createVersion(node); } - node.save(); - VersionHistoryUtils.createVersion(node); } } catch (Exception e) { throw new IllegalStateException("Error while creating new version", e); diff --git a/documents-storage-jcr/src/test/java/org/exoplatform/documents/storage/jcr/JCRDocumentFileStorageTest.java b/documents-storage-jcr/src/test/java/org/exoplatform/documents/storage/jcr/JCRDocumentFileStorageTest.java index bb796a07d..d9cf35924 100644 --- a/documents-storage-jcr/src/test/java/org/exoplatform/documents/storage/jcr/JCRDocumentFileStorageTest.java +++ b/documents-storage-jcr/src/test/java/org/exoplatform/documents/storage/jcr/JCRDocumentFileStorageTest.java @@ -1267,6 +1267,7 @@ public void createNewVersion() throws Exception { Node contentNode = mock(Node.class); Version version = mock(Version.class); when(session.getNodeByUUID("123")).thenReturn(node); + when(node.hasNode(NodeTypeConstants.JCR_CONTENT)).thenReturn(true); when(node.isNodeType(NodeTypeConstants.MIX_VERSIONABLE)).thenReturn(true); when(node.getNode(NodeTypeConstants.JCR_CONTENT)).thenReturn(contentNode); when(contentNode.hasProperty(NodeTypeConstants.JCR_DATA)).thenReturn(true); @@ -1281,6 +1282,36 @@ public void createNewVersion() throws Exception { } + @Test + public void createNewVersionAddsVersionableMixinWhenMissing() throws Exception { + // A file that isn't yet mix:versionable must be made versionable (and checked + // out) before writing, otherwise the whole version write was silently skipped. + org.exoplatform.services.security.Identity identity = mock(org.exoplatform.services.security.Identity.class); + when(identityRegistry.getIdentity("user")).thenReturn(identity); + ManageableRepository manageableRepository = mock(ManageableRepository.class); + when(repositoryService.getCurrentRepository()).thenReturn(manageableRepository); + Session session = mock(Session.class); + SessionProvider sessionProvider = mock(SessionProvider.class); + JCR_DOCUMENTS_UTIL.when(() -> JCRDocumentsUtil.getUserSessionProvider(repositoryService, identity)).thenReturn(sessionProvider); + when(sessionProvider.getSession("collaboration", manageableRepository)).thenReturn(session); + Node node = mock(Node.class); + Node contentNode = mock(Node.class); + when(session.getNodeByUUID("123")).thenReturn(node); + when(node.hasNode(NodeTypeConstants.JCR_CONTENT)).thenReturn(true); + // Not versionable initially, then versionable after the mixin is added. + when(node.isNodeType(NodeTypeConstants.MIX_VERSIONABLE)).thenReturn(false, true); + when(node.canAddMixin(NodeTypeConstants.MIX_VERSIONABLE)).thenReturn(true); + when(node.getNode(NodeTypeConstants.JCR_CONTENT)).thenReturn(contentNode); + when(contentNode.hasProperty(NodeTypeConstants.JCR_DATA)).thenReturn(true); + when(node.isCheckedOut()).thenReturn(true); + when(node.getSession()).thenReturn(session); + + jcrDocumentFileStorage.createNewVersion("123", "user", new ByteArrayInputStream("test".getBytes())); + + verify(node, times(1)).addMixin(NodeTypeConstants.MIX_VERSIONABLE); + VERSION_HISTORY_UTILS.verify(() -> VersionHistoryUtils.createVersion(node), times(1)); + } + @Test public void moveDocument() throws Exception { org.exoplatform.services.security.Identity identity = mock(org.exoplatform.services.security.Identity.class); From a3cc4bbac2eb892f065579fc0b86988db5f43f27 Mon Sep 17 00:00:00 2001 From: Benjamin Mestrallet Date: Tue, 7 Jul 2026 20:09:20 +0100 Subject: [PATCH 14/15] feat: add create_document_from_template MCP tool (delegates to ecms DocumentService) - EXO-88354 Adds a Documents MCP tool that creates a new empty office document (Word/Excel/PowerPoint, + ODF when the editor add-on provides it) from the platform's blank template, by delegating to the ecms DocumentService rather than re-implementing template/office logic. - documents-storage-jcr: inject DocumentService, add createDocumentFromTemplate mirroring createFolder (session/parent/ACL), resolve the template by matching NewDocumentTemplate.getExtension against the requested type, then call ecms createDocumentFromTemplate. - documents-api / documents-services: propagate the new storage/service signatures. - DocumentMcpTool.createDocumentFromTemplate + ai-tool-definitions entry (require_approval, document_type enum). ecms does NOT append the extension, so the tool builds a title that ends with '.'. Softened create_document's office-format rejection to point at the new tool. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../service/DocumentFileService.java | 14 +++ .../storage/DocumentFileStorage.java | 24 ++++ .../documents/mcp/DocumentMcpTool.java | 82 +++++++++++++- .../service/DocumentFileServiceImpl.java | 5 + .../main/resources/ai-tool-definitions.json | 44 +++++++- .../documents/mcp/DocumentMcpToolTest.java | 104 ++++++++++++++++++ documents-storage-jcr/pom.xml | 7 ++ .../storage/jcr/JCRDocumentFileStorage.java | 97 +++++++++++++++- .../jcr/JCRDocumentFileStorageTest.java | 6 +- 9 files changed, 377 insertions(+), 6 deletions(-) diff --git a/documents-api/src/main/java/org/exoplatform/documents/service/DocumentFileService.java b/documents-api/src/main/java/org/exoplatform/documents/service/DocumentFileService.java index 68fc34336..1421d2e9c 100644 --- a/documents-api/src/main/java/org/exoplatform/documents/service/DocumentFileService.java +++ b/documents-api/src/main/java/org/exoplatform/documents/service/DocumentFileService.java @@ -316,6 +316,20 @@ void updatePermissions(String documentId, AbstractNode createFolder(long ownerId,String folderId, String folderPath, String name, long authenticatedUserId) throws IllegalAccessException, ObjectAlreadyExistsException, ObjectNotFoundException; + /** + * Creates a new empty office document (Word/Excel/PowerPoint/ODF) from the + * platform's blank template, delegating to the ecms {@code DocumentService}. + * + * @param ownerId owner identity id (used when folderId is blank) + * @param folderId identifier of the parent folder node + * @param folderPath optional relative path under the parent folder + * @param title document title (should already include the file extension) + * @param documentType office document extension (docx, xlsx, pptx, odt, ods, odp) + * @param authenticatedUserId identity id of the user creating the document + * @return the created {@link AbstractNode} + */ + AbstractNode createDocumentFromTemplate(long ownerId, String folderId, String folderPath, String title, String documentType, long authenticatedUserId) throws IllegalAccessException, ObjectNotFoundException, ObjectAlreadyExistsException; + String getNewName(long ownerId, String folderId, String folderPath, String name) throws IllegalAccessException, ObjectAlreadyExistsException, ObjectNotFoundException; void renameDocument(long ownerId, String documentID, String name, long authenticatedUserId) throws IllegalAccessException, ObjectAlreadyExistsException, ObjectNotFoundException; diff --git a/documents-api/src/main/java/org/exoplatform/documents/storage/DocumentFileStorage.java b/documents-api/src/main/java/org/exoplatform/documents/storage/DocumentFileStorage.java index d12f69769..2e9ab9653 100644 --- a/documents-api/src/main/java/org/exoplatform/documents/storage/DocumentFileStorage.java +++ b/documents-api/src/main/java/org/exoplatform/documents/storage/DocumentFileStorage.java @@ -240,6 +240,30 @@ void moveDocument(Session session, AbstractNode createFolder(long ownerId, String folderId, String folderPath, String title, Identity aclIdentity) throws IllegalAccessException, ObjectAlreadyExistsException, ObjectNotFoundException; + /** + * Creates a new empty office document (Word/Excel/PowerPoint/ODF) from the + * platform's blank template, by delegating to the ecms {@code DocumentService}. + * + * @param ownerId owner identity id (used when folderId is blank to resolve the + * identity root folder) + * @param folderId identifier of the parent folder node + * @param folderPath optional relative path under the parent folder + * @param title document title (should already include the file extension) + * @param documentType office document extension (e.g. docx, xlsx, pptx, odt, + * ods, odp) matched against the installed template providers + * @param aclIdentity ACL identity of the user creating the document + * @return the created {@link AbstractNode} + * @throws IllegalAccessException when the user isn't allowed to edit the parent + * folder + * @throws ObjectNotFoundException when the parent folder or a matching template + * doesn't exist + * @throws ObjectAlreadyExistsException when a document with the same name + * already exists in the target folder + */ + AbstractNode createDocumentFromTemplate(long ownerId, String folderId, String folderPath, String title, String documentType, Identity aclIdentity) throws IllegalAccessException, + ObjectNotFoundException, + ObjectAlreadyExistsException; + String getNewName(long ownerId, String folderId, String folderPath, diff --git a/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java b/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java index 1df5f2737..5bff3f4a5 100644 --- a/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java +++ b/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java @@ -139,6 +139,15 @@ public class DocumentMcpTool implements McpToolPlugin { Set.of("docx", "xlsx", "pptx", "ppsx", "potx", "dotx", "xltx", "xls", "doc", "ppt", "odt", "ods", "odp", "pdf"); + /** + * Office document types that {@link #createDocumentFromTemplate} can create as a + * new empty file from the platform's blank template (via the ecms + * {@code DocumentService}). Availability of a given type at runtime still + * depends on the installed document editor add-on (onlyoffice). + */ + private static final Set SUPPORTED_TEMPLATE_TYPES = + Set.of("docx", "xlsx", "pptx", "odt", "ods", "odp"); + private final DocumentFileService documentFileService; private final AttachmentService attachmentService; @@ -436,9 +445,9 @@ public DocumentModel createDocument(String parentFolderId, } if (UNSUPPORTED_BINARY_EXTENSIONS.contains(extension)) { throw new IllegalArgumentException(("create_document writes text content as the file body, so it cannot produce a valid" - + " '%s' file — office and PDF formats are structured packages, not text. Creating an empty office document from a" - + " template isn't supported yet. For text use .txt/.md/.html, or use upload_document with the real file bytes" - + " (base64, url, or a file attached in the conversation).").formatted(extension)); + + " '%s' file — office and PDF formats are structured packages, not text. To create a NEW EMPTY office document" + + " (Word/Excel/PowerPoint/ODF) use create_document_from_template. For text use .txt/.md/.html, or use" + + " upload_document with the real file bytes (base64, url, or a file attached in the conversation).").formatted(extension)); } if (StringUtils.isBlank(content)) { throw new IllegalArgumentException("content is required to create a document (the text to write into the file)."); @@ -446,6 +455,73 @@ public DocumentModel createDocument(String parentFolderId, return importContent(parentFolderId, name, content.getBytes(StandardCharsets.UTF_8)); } + /** + * Creates a NEW EMPTY office document (Word/Excel/PowerPoint, or ODF) from the + * platform's blank template, by delegating to the ecms {@code DocumentService}. + * Use this when the user wants a blank Word/Excel/PowerPoint document to fill in + * (e.g. via the online editor): create_document cannot produce office files + * (they are structured packages, not text) and upload_document needs the real + * file bytes. The template is created synchronously; the file extension is added + * automatically from documentType. + * + * @param parentFolderId the folder where the document is created (get it from + * get_root_folder_for_user / get_root_folder_by_space / + * get_documents_by_folder_id) + * @param name the document name, WITHOUT extension (e.g. 'Q3 report'); the + * extension is appended automatically from documentType + * @param documentType the office document type: one of docx, xlsx, pptx, odt, + * ods, odp. Availability depends on the installed editor add-on. + * @return the created document + */ + public DocumentModel createDocumentFromTemplate(String parentFolderId, + String name, + String documentType) throws IllegalAccessException, ObjectNotFoundException { + checkFolderIdParameter(parentFolderId); + if (StringUtils.isBlank(name)) { + throw new IllegalArgumentException("The 'name' parameter is mandatory to create a document (e.g. 'Q3 report'). Ask the user for a document name."); + } + if (StringUtils.isBlank(documentType)) { + throw new IllegalArgumentException("The 'document_type' parameter is mandatory: the office document type to create. Supported types: " + + SUPPORTED_TEMPLATE_TYPES + "."); + } + String type = StringUtils.lowerCase(StringUtils.trimToEmpty(documentType)); + if (type.startsWith(".")) { + type = type.substring(1); + } + if (!SUPPORTED_TEMPLATE_TYPES.contains(type)) { + throw new IllegalArgumentException("Unsupported document_type '%s'. Supported office document types are: %s.".formatted(documentType, + SUPPORTED_TEMPLATE_TYPES)); + } + // Validate the parent folder exists and is readable by the user, so a bad id + // fails with a clean ObjectNotFoundException / IllegalAccessException before + // reaching the JCR storage. + getNode(parentFolderId); + // ecms names the file after the title and does NOT append the extension, so + // build a title that ends with '.' (stripping a duplicate extension the + // user may have typed to avoid 'report.docx.docx'). + String baseName = StringUtils.trimToEmpty(name); + String suffix = "." + type; + if (StringUtils.endsWithIgnoreCase(baseName, suffix)) { + baseName = baseName.substring(0, baseName.length() - suffix.length()); + } + String title = baseName + suffix; + try { + // parentFolderId already identifies the parent JCR node uniquely; the storage + // 'folderPath' is a RELATIVE sub-path resolved from that node, so keep it null + // (same rule as createFolder). + AbstractNode document = documentFileService.createDocumentFromTemplate(getOwnerId(parentFolderId), + parentFolderId, + null, + title, + type, + getCurrentUserIdentityId()); + return toDocumentModel(document); + } catch (ObjectAlreadyExistsException e) { + throw new IllegalStateException("A document named '%s' already exists under this parent folder. Tell the user to pick a different name." + .formatted(title)); + } + } + /** * Uploads a real / binary document (PDF, image, office file…) from exactly one * of three sources: a file/image the user attached in the chat conversation diff --git a/documents-services/src/main/java/org/exoplatform/documents/service/DocumentFileServiceImpl.java b/documents-services/src/main/java/org/exoplatform/documents/service/DocumentFileServiceImpl.java index 3cb164ee3..8bd61b59b 100644 --- a/documents-services/src/main/java/org/exoplatform/documents/service/DocumentFileServiceImpl.java +++ b/documents-services/src/main/java/org/exoplatform/documents/service/DocumentFileServiceImpl.java @@ -369,6 +369,11 @@ public AbstractNode createFolder(long ownerId, String folderId, String folderPat return documentFileStorage.createFolder(ownerId, folderId, folderPath, name, getAclUserIdentity(authenticatedUserId)); } + @Override + public AbstractNode createDocumentFromTemplate(long ownerId, String folderId, String folderPath, String title, String documentType, long authenticatedUserId) throws IllegalAccessException, ObjectNotFoundException, ObjectAlreadyExistsException { + return documentFileStorage.createDocumentFromTemplate(ownerId, folderId, folderPath, title, documentType, getAclUserIdentity(authenticatedUserId)); + } + @Override public String getNewName(long ownerId, String folderId, String folderPath, String name) throws IllegalAccessException, ObjectAlreadyExistsException, ObjectNotFoundException { return documentFileStorage.getNewName(ownerId, folderId, folderPath, name); diff --git a/documents-services/src/main/resources/ai-tool-definitions.json b/documents-services/src/main/resources/ai-tool-definitions.json index 486e56e51..8e1bfb45c 100644 --- a/documents-services/src/main/resources/ai-tool-definitions.json +++ b/documents-services/src/main/resources/ai-tool-definitions.json @@ -399,7 +399,7 @@ { "name": "create_document", "title": "Create a text document in the DMS", - "description": "Creates a TEXT-based document (plain text, markdown, HTML, CSV, JSON, XML) by writing the provided content string as-is into the file body. Use this to turn chat-generated text into a real file in the Documents app. The name must be a filename WITH an extension (e.g. 'notes.md', 'report.txt', 'page.html') — the extension sets the file's content type. It writes text as the file body, so use a text extension (.txt, .md, .html). It CANNOT produce binary formats: office documents (.docx, .xlsx, .pptx, .odt, etc.) and PDF are structured packages, not text, so they cannot be created here — and neither can an empty template document; use upload_document with the real file bytes (base64, url or a chat attachment) for those. Call get_root_folder_for_user, get_root_folder_by_space or get_documents_by_folder_id first to get the parent_folder_id. The stored content type is derived from the file extension by the platform: use a registered text extension (.txt, .html, .csv, etc.) so the file has the correct type and is readable via get_document_content_by_id; an unregistered extension like .md is stored as application/octet-stream and its text won't be extractable.", + "description": "Creates a TEXT-based document (plain text, markdown, HTML, CSV, JSON, XML) by writing the provided content string as-is into the file body. Use this to turn chat-generated text into a real file in the Documents app. The name must be a filename WITH an extension (e.g. 'notes.md', 'report.txt', 'page.html') — the extension sets the file's content type. It writes text as the file body, so use a text extension (.txt, .md, .html). It CANNOT produce binary formats: office documents (.docx, .xlsx, .pptx, .odt, etc.) and PDF are structured packages, not text, so they cannot be created here. To create a NEW EMPTY office document (Word/Excel/PowerPoint/ODF) use create_document_from_template; to store real file bytes use upload_document (base64, url or a chat attachment). Call get_root_folder_for_user, get_root_folder_by_space or get_documents_by_folder_id first to get the parent_folder_id. The stored content type is derived from the file extension by the platform: use a registered text extension (.txt, .html, .csv, etc.) so the file has the correct type and is readable via get_document_content_by_id; an unregistered extension like .md is stored as application/octet-stream and its text won't be extractable.", "input_schema": { "type": "object", "properties": { @@ -430,6 +430,48 @@ }, "require_approval": true }, + { + "name": "create_document_from_template", + "title": "Create an empty office document from a template", + "description": "Creates a NEW EMPTY office document (Word, Excel, PowerPoint, or ODF) from the platform's blank template, ready to edit in the online editor. Use this when the user wants a blank Word/Excel/PowerPoint document to fill in — create_document CANNOT produce office files (they are structured packages, not text) and upload_document needs the real file bytes. Call get_root_folder_for_user, get_root_folder_by_space or get_documents_by_folder_id first to get the parent_folder_id. The file extension is added automatically from document_type, so pass 'name' without an extension. Availability of a given type depends on the installed document editor add-on (onlyoffice).", + "input_schema": { + "type": "object", + "properties": { + "parent_folder_id": { + "type": "string", + "description": "The unique identifier of the folder where the document is created." + }, + "name": { + "type": "string", + "description": "The document name, without extension (e.g. 'Q3 report'); the extension is appended automatically from document_type." + }, + "document_type": { + "type": "string", + "description": "The office document type to create. The matching file extension is added automatically.", + "enum": [ + "docx", + "xlsx", + "pptx", + "odt", + "ods", + "odp" + ] + } + }, + "required": [ + "parent_folder_id", + "name", + "document_type" + ] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "require_approval": true + }, { "name": "upload_document", "title": "Upload a file to the DMS", diff --git a/documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java b/documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java index a5d83a9c1..d594b05d1 100644 --- a/documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java +++ b/documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java @@ -569,6 +569,110 @@ public void createDocumentBlankContentFails() { assertTrue(blankContent.getMessage().contains("content is required")); } + @Test + public void createDocumentFromTemplateSucceeds() throws Exception { + when(documentFileService.getDocumentById(FOLDER_ID, USERNAME)).thenReturn(folderNode(FOLDER_ID)); + when(documentFileService.getRootFolderOwnerId(FOLDER_ID)).thenReturn(OWNER_ID); + // ecms names the file after the title (with the extension), so the tool builds + // "Q3 report.docx" and passes the normalized type "docx". + when(documentFileService.createDocumentFromTemplate(eq(OWNER_ID), + eq(FOLDER_ID), + isNull(), + eq("Q3 report.docx"), + eq("docx"), + eq(USER_IDENTITY_ID))) + .thenReturn(fileNodeNamed("doc-tpl", "q3 report.docx")); + + DocumentModel model = documentMcpTool.createDocumentFromTemplate(FOLDER_ID, "Q3 report", "docx"); + + assertNotNull(model); + assertEquals("doc-tpl", model.getId()); + verify(documentFileService).createDocumentFromTemplate(eq(OWNER_ID), + eq(FOLDER_ID), + isNull(), + eq("Q3 report.docx"), + eq("docx"), + eq(USER_IDENTITY_ID)); + } + + @Test + public void createDocumentFromTemplateStripsDuplicateExtensionAndNormalizesType() throws Exception { + when(documentFileService.getDocumentById(FOLDER_ID, USERNAME)).thenReturn(folderNode(FOLDER_ID)); + when(documentFileService.getRootFolderOwnerId(FOLDER_ID)).thenReturn(OWNER_ID); + when(documentFileService.createDocumentFromTemplate(eq(OWNER_ID), + eq(FOLDER_ID), + isNull(), + eq("budget.xlsx"), + eq("xlsx"), + eq(USER_IDENTITY_ID))) + .thenReturn(fileNodeNamed("doc-xls", "budget.xlsx")); + + // name already carries the extension and document_type is upper-cased with a + // leading dot: the tool must not produce "budget.xlsx.xlsx" nor pass ".XLSX". + DocumentModel model = documentMcpTool.createDocumentFromTemplate(FOLDER_ID, "budget.xlsx", ".XLSX"); + + assertNotNull(model); + assertEquals("doc-xls", model.getId()); + verify(documentFileService).createDocumentFromTemplate(eq(OWNER_ID), + eq(FOLDER_ID), + isNull(), + eq("budget.xlsx"), + eq("xlsx"), + eq(USER_IDENTITY_ID)); + } + + @Test + public void createDocumentFromTemplateBlankNameFails() { + assertThrows(IllegalArgumentException.class, () -> documentMcpTool.createDocumentFromTemplate(FOLDER_ID, " ", "docx")); + } + + @Test + public void createDocumentFromTemplateBlankTypeFails() { + assertThrows(IllegalArgumentException.class, () -> documentMcpTool.createDocumentFromTemplate(FOLDER_ID, "report", " ")); + } + + @Test + public void createDocumentFromTemplateUnsupportedTypeFails() { + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, + () -> documentMcpTool.createDocumentFromTemplate(FOLDER_ID, "report", "pdf")); + assertTrue(ex.getMessage().contains("Unsupported document_type")); + } + + @Test + public void createDocumentFromTemplateBlankFolderFails() { + assertThrows(IllegalArgumentException.class, () -> documentMcpTool.createDocumentFromTemplate(" ", "report", "docx")); + } + + @Test + public void createDocumentFromTemplateBadFolderFails() throws Exception { + when(documentFileService.getDocumentById("missing", USERNAME)).thenThrow(new ObjectNotFoundException("no node")); + assertThrows(ObjectNotFoundException.class, + () -> documentMcpTool.createDocumentFromTemplate("missing", "report", "docx")); + } + + @Test + public void createDocumentFromTemplateNoEditPermissionFails() throws Exception { + when(documentFileService.getDocumentById(FOLDER_ID, USERNAME)).thenReturn(folderNode(FOLDER_ID)); + when(documentFileService.getRootFolderOwnerId(FOLDER_ID)).thenReturn(OWNER_ID); + when(documentFileService.createDocumentFromTemplate(anyLong(), anyString(), isNull(), anyString(), anyString(), anyLong())) + .thenThrow(new IllegalAccessException("Permission to add document is missing")); + assertThrows(IllegalAccessException.class, + () -> documentMcpTool.createDocumentFromTemplate(FOLDER_ID, "report", "docx")); + } + + @Test + public void createDocumentFromTemplateNameClashFails() throws Exception { + when(documentFileService.getDocumentById(FOLDER_ID, USERNAME)).thenReturn(folderNode(FOLDER_ID)); + when(documentFileService.getRootFolderOwnerId(FOLDER_ID)).thenReturn(OWNER_ID); + when(documentFileService.createDocumentFromTemplate(anyLong(), anyString(), isNull(), anyString(), anyString(), anyLong())) + .thenThrow(new ObjectAlreadyExistsException("exists")); + IllegalStateException ex = + assertThrows(IllegalStateException.class, + () -> documentMcpTool.createDocumentFromTemplate(FOLDER_ID, "report", "docx")); + assertTrue(ex.getMessage().contains("already exists")); + } + @Test public void uploadDocumentFromBase64() throws Exception { when(documentFileService.getDocumentById(FOLDER_ID, USERNAME)).thenReturn(folderNode(FOLDER_ID)); diff --git a/documents-storage-jcr/pom.xml b/documents-storage-jcr/pom.xml index a05bde77b..589c2f13e 100644 --- a/documents-storage-jcr/pom.xml +++ b/documents-storage-jcr/pom.xml @@ -57,6 +57,13 @@ jar provided + + + org.exoplatform.ecms + ecms-core-services + ${addon.exo.ecms.version} + provided + diff --git a/documents-storage-jcr/src/main/java/org/exoplatform/documents/storage/jcr/JCRDocumentFileStorage.java b/documents-storage-jcr/src/main/java/org/exoplatform/documents/storage/jcr/JCRDocumentFileStorage.java index 50d8dc003..3e589d104 100644 --- a/documents-storage-jcr/src/main/java/org/exoplatform/documents/storage/jcr/JCRDocumentFileStorage.java +++ b/documents-storage-jcr/src/main/java/org/exoplatform/documents/storage/jcr/JCRDocumentFileStorage.java @@ -54,6 +54,9 @@ import org.exoplatform.documents.storage.jcr.util.JCRDocumentsUtil; import org.exoplatform.documents.storage.jcr.util.NodeTypeConstants; import org.exoplatform.documents.storage.jcr.util.Utils; +import org.exoplatform.services.cms.documents.DocumentService; +import org.exoplatform.services.cms.documents.NewDocumentTemplate; +import org.exoplatform.services.cms.documents.NewDocumentTemplateProvider; import org.exoplatform.services.jcr.RepositoryService; import org.exoplatform.services.jcr.access.AccessControlEntry; import org.exoplatform.services.jcr.access.PermissionType; @@ -145,6 +148,8 @@ public class JCRDocumentFileStorage implements DocumentFileStorage { private final BulkStorageActionService bulkStorageActionService; + private final DocumentService documentService; + private final String EVENT_DOCUMENT_MOVED = "exo-document-moved"; private final String DEST_PATH = "destPath"; @@ -195,7 +200,8 @@ public JCRDocumentFileStorage(NodeHierarchyCreator nodeHierarchyCreator, UploadService uploadService, IdentityRegistry identityRegistry, ActivityManager activityManager, - BulkStorageActionService bulkStorageActionService) { + BulkStorageActionService bulkStorageActionService, + DocumentService documentService) { this.identityManager = identityManager; this.spaceService = spaceService; this.repositoryService = repositoryService; @@ -206,6 +212,7 @@ public JCRDocumentFileStorage(NodeHierarchyCreator nodeHierarchyCreator, this.identityRegistry = identityRegistry; this.activityManager = activityManager; this.bulkStorageActionService = bulkStorageActionService; + this.documentService = documentService; } @Override @@ -1045,6 +1052,94 @@ public AbstractNode createFolder(long ownerId, } } + @Override + public AbstractNode createDocumentFromTemplate(long ownerId, + String folderId, + String folderPath, + String title, + String documentType, + Identity aclIdentity) throws IllegalAccessException, + ObjectNotFoundException, + ObjectAlreadyExistsException { + if (!JCRDocumentsUtil.isValidDocumentTitle(title)) { + throw new IllegalArgumentException("document title is not valid"); + } + String extension = StringUtils.isBlank(documentType) ? "" : documentType.trim(); + if (extension.startsWith(".")) { + extension = extension.substring(1); + } + String username = aclIdentity.getUserId(); + SessionProvider sessionProvider = null; + try { + Node node = null; + ManageableRepository manageableRepository = repositoryService.getCurrentRepository(); + sessionProvider = getUserSessionProvider(repositoryService, aclIdentity); + Session session = sessionProvider.getSession(COLLABORATION, manageableRepository); + if (StringUtils.isBlank(folderId) && ownerId > 0) { + org.exoplatform.social.core.identity.model.Identity ownerIdentity = identityManager.getIdentity(String.valueOf(ownerId)); + node = getIdentityRootNode(spaceService, nodeHierarchyCreator, username, ownerIdentity, sessionProvider); + folderId = ((NodeImpl) node).getIdentifier(); + } else { + node = getNodeByIdentifier(session, folderId); + } + if (StringUtils.isNotBlank(folderPath)) { + try { + node = node.getNode(java.net.URLDecoder.decode(folderPath, StandardCharsets.UTF_8).replace("%", "%25")); + } catch (RepositoryException repositoryException) { + throw new ObjectNotFoundException("Folder with path : " + folderPath + " isn't found"); + } + } + Map nodeAccessList = countNodeAccessList(node, aclIdentity); + String canEdit = "canEdit"; + if (nodeAccessList.containsKey(canEdit) && !nodeAccessList.get(canEdit).booleanValue()) { + throw new IllegalAccessException("Permission to add document is missing"); + } + // no need to this object later make it eligible to the garbage collactor + nodeAccessList = null; + // Resolve the template from the installed document editor add-on providers + NewDocumentTemplate template = null; + List availableExtensions = new ArrayList<>(); + for (NewDocumentTemplateProvider provider : documentService.getNewDocumentTemplateProviders()) { + if (provider == null || provider.getTemplates() == null) { + continue; + } + for (NewDocumentTemplate candidate : provider.getTemplates()) { + if (candidate == null || candidate.getExtension() == null) { + continue; + } + availableExtensions.add(candidate.getExtension()); + if (candidate.getExtension().equalsIgnoreCase(extension)) { + template = candidate; + break; + } + } + if (template != null) { + break; + } + } + if (template == null) { + throw new ObjectNotFoundException("No document template found for extension '" + extension + + "'. Available template extensions: " + availableExtensions); + } + Node createdNode = documentService.createDocumentFromTemplate(node, title, template); + // defensive save (ecms saves internally, but the parent may still hold pending state) + node.save(); + return toFileNode(identityManager, aclIdentity, createdNode, "", spaceService); + } catch (IllegalAccessException exception) { + throw new IllegalAccessException(exception.getMessage()); + } catch (ObjectNotFoundException e) { + throw new ObjectNotFoundException(e.getMessage()); + } catch (javax.jcr.ItemExistsException e) { + throw new ObjectAlreadyExistsException("A document named '" + title + "' already exists in the target folder"); + } catch (Exception e) { + throw new IllegalStateException("Error creating document from template in folder '" + folderId + "'", e); + } finally { + if (sessionProvider != null) { + sessionProvider.close(); + } + } + } + @Override public String getNewName(long ownerId, String folderId, diff --git a/documents-storage-jcr/src/test/java/org/exoplatform/documents/storage/jcr/JCRDocumentFileStorageTest.java b/documents-storage-jcr/src/test/java/org/exoplatform/documents/storage/jcr/JCRDocumentFileStorageTest.java index d9cf35924..624a342de 100644 --- a/documents-storage-jcr/src/test/java/org/exoplatform/documents/storage/jcr/JCRDocumentFileStorageTest.java +++ b/documents-storage-jcr/src/test/java/org/exoplatform/documents/storage/jcr/JCRDocumentFileStorageTest.java @@ -112,6 +112,9 @@ public class JCRDocumentFileStorageTest { @Mock private BulkStorageActionService bulkStorageActionService; + @Mock + private org.exoplatform.services.cms.documents.DocumentService documentService; + private JCRDocumentFileStorage jcrDocumentFileStorage; @@ -135,7 +138,8 @@ public void setUp() { uploadService, identityRegistry, activityManager, - bulkStorageActionService); + bulkStorageActionService, + documentService); } @Test From 262d0f8351d41ccd8e19aa9b126a5ca279b9b427 Mon Sep 17 00:00:00 2001 From: Benjamin Mestrallet Date: Tue, 7 Jul 2026 20:35:16 +0100 Subject: [PATCH 15/15] =?UTF-8?q?fix:=20re-verify=20follow-ups=20=E2=80=94?= =?UTF-8?q?=20template=20extension=20match,=20share=20no-downgrade,=20undo?= =?UTF-8?q?-delete=20system=20session,=20favorite=20docs=20-=20EXO-88354?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix 1: create_document_from_template never matched a template because onlyoffice stores extensions with a leading dot (".docx") while the caller's type is dot-stripped ("docx"). Normalize the dot on both sides of the compare in JCRDocumentFileStorage. Trim supported types to docx/xlsx/pptx (the 3 the editor add-on actually ships) in DocumentMcpTool and ai-tool-definitions.json. Fix 2: share_document silently downgraded an existing editor to read because it always appended (recipient,"read") after the preserved (recipient,"edit"), and storage writes permissions in list order. Only add the read grant when the recipient is not already a collaborator. Fix 3: undo_delete_document resolved the trashed node via the user-session overload, but trash lives under a system session, so it false-negatived with a misleading "permanently deleted" error. Use the system-session getDocumentById overload. Fix 4: favorite/unfavorite descriptions and Javadoc advertised "file or folder" though folders are rejected. Correct to files-only wording. Adds regression tests: template dotted-extension match (storage), share edit-preservation, and undo-delete via the system-session overload. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../documents/mcp/DocumentMcpTool.java | 55 +++++++++++++----- .../main/resources/ai-tool-definitions.json | 19 +++--- .../documents/mcp/DocumentMcpToolTest.java | 58 ++++++++++++++++++- .../storage/jcr/JCRDocumentFileStorage.java | 5 +- .../jcr/JCRDocumentFileStorageTest.java | 47 +++++++++++++++ 5 files changed, 155 insertions(+), 29 deletions(-) diff --git a/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java b/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java index 5bff3f4a5..ab3ae8b4e 100644 --- a/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java +++ b/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java @@ -146,7 +146,7 @@ public class DocumentMcpTool implements McpToolPlugin { * depends on the installed document editor add-on (onlyoffice). */ private static final Set SUPPORTED_TEMPLATE_TYPES = - Set.of("docx", "xlsx", "pptx", "odt", "ods", "odp"); + Set.of("docx", "xlsx", "pptx"); private final DocumentFileService documentFileService; @@ -469,8 +469,8 @@ public DocumentModel createDocument(String parentFolderId, * get_documents_by_folder_id) * @param name the document name, WITHOUT extension (e.g. 'Q3 report'); the * extension is appended automatically from documentType - * @param documentType the office document type: one of docx, xlsx, pptx, odt, - * ods, odp. Availability depends on the installed editor add-on. + * @param documentType the office document type: one of docx, xlsx, pptx. + * Availability depends on the installed editor add-on (onlyoffice). * @return the created document */ public DocumentModel createDocumentFromTemplate(String parentFolderId, @@ -693,13 +693,12 @@ public void deleteDocument(String documentId) throws IllegalAccessException, Obj */ public void undoDeleteDocument(String documentId) throws IllegalAccessException, ObjectNotFoundException { checkDocumentIdParameter(documentId); - AbstractNode trashedNode; - try { - trashedNode = documentFileService.getDocumentById(documentId, getCurrentUserName()); - } catch (ObjectNotFoundException e) { - throw new IllegalStateException(("Document %s can no longer be found (it may have been permanently deleted from the trash), " - + "so it cannot be restored.").formatted(documentId)); - } + // Resolve the trashed node through the SYSTEM-session overload: all trash + // access in storage uses a system session, and a regular user session cannot + // read a node once it has been moved to trash. Using the user-session overload + // here would return null even though the node still exists, yielding a + // misleading "permanently deleted" error. + AbstractNode trashedNode = documentFileService.getDocumentById(documentId); if (trashedNode == null) { throw new IllegalStateException(("Document %s can no longer be found (it may have been permanently deleted from the trash), " + "so it cannot be restored.").formatted(documentId)); @@ -928,7 +927,14 @@ public void shareDocument(String documentId, // (creates the symlink). The current collaborators are preserved so they are // not unshared by the storage's replace-then-unshare logic. List permissions = preserveExistingCollaborators(node.getAcl()); - permissions.add(new PermissionEntry(destIdentity, "read", PermissionRole.ALL.name())); + // Only grant the recipient "read" if it is not already a collaborator with an + // equal-or-greater permission. updatePermissions writes the map in list order, + // so appending (recipient,"read") after preserveExistingCollaborators already + // emitted (recipient,"edit") would silently DOWNGRADE the recipient to read + // (this also happens when a space document is shared back to its own space). + if (!alreadyGrantsAccess(permissions, destIdentity)) { + permissions.add(new PermissionEntry(destIdentity, "read", PermissionRole.ALL.name())); + } Map toShare = new HashMap<>(); toShare.put(destId, "read"); Map toNotify = new HashMap<>(); @@ -986,9 +992,27 @@ private static boolean isEditAclPermission(String permission) { } /** - * Adds a document (file or folder) to the current user's favorites. + * Whether the reconstructed collaborator list already contains the given + * identity with any grant (read or edit). When true, the share must NOT append + * a (recipient,"read") entry, as that would overwrite an existing higher grant + * (edit) when the storage writes the permissions map in list order. + */ + private static boolean alreadyGrantsAccess(List permissions, Identity identity) { + if (permissions == null || identity == null) { + return false; + } + return permissions.stream().anyMatch(entry -> { + Identity existing = entry.getIdentity(); + return existing != null && StringUtils.equals(existing.getProviderId(), identity.getProviderId()) + && StringUtils.equals(existing.getRemoteId(), identity.getRemoteId()); + }); + } + + /** + * Adds a file to the current user's favorites. Only files can be favorited; + * folders have no favorites view in the Documents app. * - * @param documentId the id of the file or folder to mark as favorite + * @param documentId the id of the file to mark as favorite */ public void favoriteDocument(String documentId) throws IllegalAccessException, ObjectNotFoundException { Favorite favorite = toFavorite(documentId); @@ -1000,9 +1024,10 @@ public void favoriteDocument(String documentId) throws IllegalAccessException, O } /** - * Removes a document (file or folder) from the current user's favorites. + * Removes a file from the current user's favorites. Only files can be + * favorited; folders have no favorites view in the Documents app. * - * @param documentId the id of the file or folder to remove from favorites + * @param documentId the id of the file to remove from favorites */ public void unfavoriteDocument(String documentId) throws IllegalAccessException, ObjectNotFoundException { Favorite favorite = toFavorite(documentId); diff --git a/documents-services/src/main/resources/ai-tool-definitions.json b/documents-services/src/main/resources/ai-tool-definitions.json index 8e1bfb45c..91751595f 100644 --- a/documents-services/src/main/resources/ai-tool-definitions.json +++ b/documents-services/src/main/resources/ai-tool-definitions.json @@ -433,7 +433,7 @@ { "name": "create_document_from_template", "title": "Create an empty office document from a template", - "description": "Creates a NEW EMPTY office document (Word, Excel, PowerPoint, or ODF) from the platform's blank template, ready to edit in the online editor. Use this when the user wants a blank Word/Excel/PowerPoint document to fill in — create_document CANNOT produce office files (they are structured packages, not text) and upload_document needs the real file bytes. Call get_root_folder_for_user, get_root_folder_by_space or get_documents_by_folder_id first to get the parent_folder_id. The file extension is added automatically from document_type, so pass 'name' without an extension. Availability of a given type depends on the installed document editor add-on (onlyoffice).", + "description": "Creates a NEW EMPTY office document (Word, Excel, or PowerPoint) from the platform's blank template, ready to edit in the online editor. Use this when the user wants a blank Word/Excel/PowerPoint document to fill in — create_document CANNOT produce office files (they are structured packages, not text) and upload_document needs the real file bytes. Call get_root_folder_for_user, get_root_folder_by_space or get_documents_by_folder_id first to get the parent_folder_id. The file extension is added automatically from document_type, so pass 'name' without an extension. Availability of a given type depends on the installed document editor add-on (onlyoffice).", "input_schema": { "type": "object", "properties": { @@ -451,10 +451,7 @@ "enum": [ "docx", "xlsx", - "pptx", - "odt", - "ods", - "odp" + "pptx" ] } }, @@ -923,14 +920,14 @@ }, { "name": "favorite_document", - "title": "Add a DMS file or folder to favorites", - "description": "Adds a document (file or folder) to the current user's favorites. Call get_document_by_id or get_documents_by_folder_id first to get the document_id.", + "title": "Add a DMS file to favorites", + "description": "Adds a file to the current user's favorites. Only files can be favorited; folders have no favorites view. Call get_document_by_id or get_documents_by_folder_id first to get the document_id.", "input_schema": { "type": "object", "properties": { "document_id": { "type": "string", - "description": "The unique identifier of the file or folder to mark as favorite." + "description": "The unique identifier of the file to mark as favorite." } }, "required": [ @@ -947,14 +944,14 @@ }, { "name": "unfavorite_document", - "title": "Remove a DMS file or folder from favorites", - "description": "Removes a document (file or folder) from the current user's favorites. Call get_document_by_id or get_documents_by_folder_id first to get the document_id.", + "title": "Remove a DMS file from favorites", + "description": "Removes a file from the current user's favorites. Only files can be favorited; folders have no favorites view. Call get_document_by_id or get_documents_by_folder_id first to get the document_id.", "input_schema": { "type": "object", "properties": { "document_id": { "type": "string", - "description": "The unique identifier of the file or folder to remove from favorites." + "description": "The unique identifier of the file to remove from favorites." } }, "required": [ diff --git a/documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java b/documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java index d594b05d1..cbd25f8e8 100644 --- a/documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java +++ b/documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java @@ -62,6 +62,8 @@ import org.exoplatform.documents.model.FolderNode; import org.exoplatform.documents.model.FullTreeItem; import org.exoplatform.documents.model.NodePermission; +import org.exoplatform.documents.model.PermissionEntry; +import org.exoplatform.documents.model.PermissionRole; import org.exoplatform.documents.model.PublicDocumentAccess; import org.exoplatform.documents.service.DocumentFileService; import org.exoplatform.documents.service.PublicDocumentAccessService; @@ -852,18 +854,26 @@ public void undoDeleteDocumentRestoresFromTrash() throws Exception { // actually restore it from trash (the delayed-delete cancel path is a no-op). FileNode trashed = fileNode(DOCUMENT_ID, "application/pdf"); trashed.setPath("/Trash/report.pdf"); - when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(trashed); + // The trashed node must be resolved through the SYSTEM-session overload + // (getDocumentById(id)), not the user-session one: trash lives under a system + // session and a regular user session cannot read the trashed node. + when(documentFileService.getDocumentById(DOCUMENT_ID)).thenReturn(trashed); documentMcpTool.undoDeleteDocument(DOCUMENT_ID); verify(documentFileService).restoreDocumentFromTrash(eq("/Trash/report.pdf")); + // It must use the system-session overload, NOT the user-session one (which would + // false-negative on a trashed node). + verify(documentFileService).getDocumentById(DOCUMENT_ID); + verify(documentFileService, never()).getDocumentById(DOCUMENT_ID, USERNAME); // It must NOT rely on the no-op delayed-delete cancel. verify(documentFileService, never()).undoDeleteDocument(anyString(), anyLong()); } @Test public void undoDeleteDocumentNotFoundFails() throws Exception { - when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenThrow(new ObjectNotFoundException("gone")); + // The system-session overload returns null when the node is genuinely gone. + when(documentFileService.getDocumentById(DOCUMENT_ID)).thenReturn(null); // Never silently succeed: a document no longer in trash must surface a clear error. assertThrows(IllegalStateException.class, () -> documentMcpTool.undoDeleteDocument(DOCUMENT_ID)); @@ -1095,6 +1105,50 @@ public void shareDocumentWithoutEditPermissionFails() throws Exception { assertThrows(IllegalAccessException.class, () -> documentMcpTool.shareDocument(DOCUMENT_ID, "bob", null, null)); } + @Test + public void shareDocumentDoesNotDowngradeExistingEditor() throws Exception { + // The recipient already collaborates on the document with EDIT (an "add_node" + // raw JCR permission). Sharing must not append a (recipient,"read") entry after + // the preserved (recipient,"edit"), which the storage's list-ordered write would + // otherwise use to silently DOWNGRADE the recipient to read. + org.exoplatform.social.core.identity.model.Identity bob = + new org.exoplatform.social.core.identity.model.Identity("200"); + bob.setProviderId("organization"); + bob.setRemoteId("bob"); + when(identityManager.getOrCreateUserIdentity("bob")).thenReturn(bob); + + org.exoplatform.social.core.identity.model.Identity bobInAcl = + new org.exoplatform.social.core.identity.model.Identity("200"); + bobInAcl.setProviderId("organization"); + bobInAcl.setRemoteId("bob"); + FileNode node = fileNode(DOCUMENT_ID, "application/pdf"); + node.setAcl(new NodePermission(true, + true, + false, + false, + List.of(new PermissionEntry(bobInAcl, "add_node", PermissionRole.ALL.name())), + new java.util.HashMap<>(), + new java.util.HashMap<>(), + null)); + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(node); + when(documentFileService.hasEditPermissionOnDocument(DOCUMENT_ID, USER_IDENTITY_ID)).thenReturn(true); + + documentMcpTool.shareDocument(DOCUMENT_ID, "bob", null, null); + + verify(documentFileService).updatePermissions(eq(DOCUMENT_ID), + argThat((NodePermission np) -> np.getPermissions() + .stream() + .anyMatch(p -> p.getIdentity() + .getIdentityId() == 200L + && "edit".equals(p.getPermission())) + && np.getPermissions() + .stream() + .noneMatch(p -> p.getIdentity() + .getIdentityId() == 200L + && "read".equals(p.getPermission()))), + eq(USER_IDENTITY_ID)); + } + @Test public void favoriteDocumentCreatesFavorite() throws Exception { when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(fileNode(DOCUMENT_ID, "application/pdf")); diff --git a/documents-storage-jcr/src/main/java/org/exoplatform/documents/storage/jcr/JCRDocumentFileStorage.java b/documents-storage-jcr/src/main/java/org/exoplatform/documents/storage/jcr/JCRDocumentFileStorage.java index 3e589d104..a33ac7abf 100644 --- a/documents-storage-jcr/src/main/java/org/exoplatform/documents/storage/jcr/JCRDocumentFileStorage.java +++ b/documents-storage-jcr/src/main/java/org/exoplatform/documents/storage/jcr/JCRDocumentFileStorage.java @@ -1108,7 +1108,10 @@ public AbstractNode createDocumentFromTemplate(long ownerId, continue; } availableExtensions.add(candidate.getExtension()); - if (candidate.getExtension().equalsIgnoreCase(extension)) { + // onlyoffice stores extensions with a leading dot (".docx"); the + // caller-provided extension is already dot-stripped above, so strip + // the candidate's dot too before comparing. + if (StringUtils.removeStart(candidate.getExtension(), ".").equalsIgnoreCase(extension)) { template = candidate; break; } diff --git a/documents-storage-jcr/src/test/java/org/exoplatform/documents/storage/jcr/JCRDocumentFileStorageTest.java b/documents-storage-jcr/src/test/java/org/exoplatform/documents/storage/jcr/JCRDocumentFileStorageTest.java index 624a342de..453503a96 100644 --- a/documents-storage-jcr/src/test/java/org/exoplatform/documents/storage/jcr/JCRDocumentFileStorageTest.java +++ b/documents-storage-jcr/src/test/java/org/exoplatform/documents/storage/jcr/JCRDocumentFileStorageTest.java @@ -51,6 +51,8 @@ import org.exoplatform.services.jcr.impl.core.SessionImpl; import org.exoplatform.services.jcr.impl.core.WorkspaceImpl; import org.exoplatform.services.jcr.impl.core.query.QueryImpl; +import org.exoplatform.services.cms.documents.NewDocumentTemplate; +import org.exoplatform.services.cms.documents.NewDocumentTemplateProvider; import org.exoplatform.services.listener.ListenerService; import org.exoplatform.services.security.IdentityRegistry; import org.exoplatform.social.core.activity.model.ExoSocialActivity; @@ -231,6 +233,51 @@ public void shareDocument() throws Exception { verify(sessionProvider, atLeast(1)).close(); } + @Test + public void createDocumentFromTemplateMatchesDottedExtension() throws Exception { + // Regression: onlyoffice stores the template extension WITH a leading dot + // (".docx"). The caller passes "docx" (dot-stripped). The match must normalize + // the dot on both sides, otherwise ".docx".equalsIgnoreCase("docx") is always + // false and every call wrongly throws ObjectNotFoundException. + org.exoplatform.services.security.Identity aclIdentity = new org.exoplatform.services.security.Identity("john"); + SessionProvider sessionProvider = mock(SessionProvider.class); + JCR_DOCUMENTS_UTIL.when(() -> JCRDocumentsUtil.getUserSessionProvider(repositoryService, aclIdentity)) + .thenReturn(sessionProvider); + ManageableRepository manageableRepository = mock(ManageableRepository.class); + when(repositoryService.getCurrentRepository()).thenReturn(manageableRepository); + Session session = mock(Session.class); + when(sessionProvider.getSession("collaboration", manageableRepository)).thenReturn(session); + + ExtendedNode folderNode = mock(ExtendedNode.class); + JCR_DOCUMENTS_UTIL.when(() -> JCRDocumentsUtil.getNodeByIdentifier(session, "1")).thenReturn(folderNode); + JCR_DOCUMENTS_UTIL.when(() -> JCRDocumentsUtil.isValidDocumentTitle("Q3 report.docx")).thenReturn(true); + // Grant the user edit access so countNodeAccessList reports canEdit=true. + AccessControlEntry editEntry = new AccessControlEntry("john", PermissionType.ADD_NODE); + when(folderNode.getACL()).thenReturn(new AccessControlList("john", Arrays.asList(editEntry))); + + // A template whose extension carries the leading dot, as onlyoffice stores it. + NewDocumentTemplate template = mock(NewDocumentTemplate.class); + when(template.getExtension()).thenReturn(".docx"); + NewDocumentTemplateProvider provider = mock(NewDocumentTemplateProvider.class); + when(provider.getTemplates()).thenReturn(Arrays.asList(template)); + when(documentService.getNewDocumentTemplateProviders()).thenReturn(Arrays.asList(provider)); + + Node createdNode = mock(Node.class); + when(documentService.createDocumentFromTemplate(folderNode, "Q3 report.docx", template)).thenReturn(createdNode); + FileNode expected = new FileNode(); + expected.setId("created-1"); + JCR_DOCUMENTS_UTIL.when(() -> JCRDocumentsUtil.toFileNode(identityManager, aclIdentity, createdNode, "", spaceService)) + .thenReturn(expected); + + AbstractNode result = jcrDocumentFileStorage.createDocumentFromTemplate(0L, "1", null, "Q3 report.docx", "docx", aclIdentity); + + // The dotted template must be matched (no ObjectNotFoundException) and used to + // create the document. + assertNotNull(result); + assertEquals("created-1", result.getId()); + verify(documentService).createDocumentFromTemplate(folderNode, "Q3 report.docx", template); + } + @Test public void duplicateDocument() throws Exception { Session systemSession = mock(Session.class);