diff --git a/apps/server/src/core/comment/comment.service.ts b/apps/server/src/core/comment/comment.service.ts index 6dcae6df14..e777508d30 100644 --- a/apps/server/src/core/comment/comment.service.ts +++ b/apps/server/src/core/comment/comment.service.ts @@ -226,6 +226,10 @@ export class CommentService { return updatedComment; } + async delete(commentId: string): Promise { + await this.commentRepo.deleteComment(commentId); + } + private async queueCommentNotification( content: any, oldMentionIds: string[], diff --git a/apps/server/src/integrations/mcp/mcp-server.factory.ts b/apps/server/src/integrations/mcp/mcp-server.factory.ts index cd4bb49789..3492f1fd41 100644 --- a/apps/server/src/integrations/mcp/mcp-server.factory.ts +++ b/apps/server/src/integrations/mcp/mcp-server.factory.ts @@ -64,6 +64,7 @@ export class McpServerFactory { this.commentService, this.pageRepo, this.pageAccessService, + this.spaceAbility, ); registerOtherTools( server, diff --git a/apps/server/src/integrations/mcp/tools/comment.tools.spec.ts b/apps/server/src/integrations/mcp/tools/comment.tools.spec.ts index 5ab34e5327..077b42da56 100644 --- a/apps/server/src/integrations/mcp/tools/comment.tools.spec.ts +++ b/apps/server/src/integrations/mcp/tools/comment.tools.spec.ts @@ -25,6 +25,8 @@ describe('Comment Tools Authorization', () => { let commentService: Record; let pageRepo: Record; let pageAccessService: Record; + let spaceAbility: Record; + let mockAbility: { can: jest.Mock; cannot: jest.Mock }; beforeEach(() => { toolHandlers.clear(); @@ -37,11 +39,17 @@ describe('Comment Tools Authorization', () => { }), }; + mockAbility = { + can: jest.fn().mockReturnValue(true), + cannot: jest.fn().mockReturnValue(false), + }; + commentService = { findByPageId: jest.fn().mockResolvedValue({ items: [mockComment] }), create: jest.fn().mockResolvedValue(mockComment), findById: jest.fn().mockResolvedValue(mockComment), update: jest.fn().mockResolvedValue({ ...mockComment, updatedAt: new Date() }), + delete: jest.fn().mockResolvedValue(undefined), }; pageRepo = { @@ -53,6 +61,10 @@ describe('Comment Tools Authorization', () => { validateCanEdit: jest.fn().mockResolvedValue({ hasRestriction: false }), }; + spaceAbility = { + createForUser: jest.fn().mockResolvedValue(mockAbility), + }; + registerCommentTools( mockServer as any, mockUser, @@ -60,6 +72,7 @@ describe('Comment Tools Authorization', () => { commentService as any, pageRepo as any, pageAccessService as any, + spaceAbility as any, ); }); @@ -158,4 +171,76 @@ describe('Comment Tools Authorization', () => { expect(result.content[0].text).toContain('not found'); }); }); + + describe('delete_comment', () => { + it('should delete comment when user is the owner', async () => { + const result = await callTool('delete_comment', { commentId: 'comment-1' }); + + expect(commentService.findById).toHaveBeenCalledWith('comment-1'); + expect(pageAccessService.validateCanEdit).toHaveBeenCalledWith(mockPage, mockUser); + expect(commentService.delete).toHaveBeenCalledWith('comment-1'); + expect(result.isError).toBe(false); + }); + + it('should delete comment when user is space admin', async () => { + const otherUserComment = { ...mockComment, creatorId: 'other-user' }; + commentService.findById.mockResolvedValue(otherUserComment); + + const result = await callTool('delete_comment', { commentId: 'comment-1' }); + + expect(spaceAbility.createForUser).toHaveBeenCalledWith(mockUser, 'space-1'); + expect(commentService.delete).toHaveBeenCalledWith('comment-1'); + expect(result.isError).toBe(false); + }); + + it('should deny when user cannot edit page', async () => { + pageAccessService.validateCanEdit.mockRejectedValue(new ForbiddenException()); + + const result = await callTool('delete_comment', { commentId: 'comment-1' }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Permission denied'); + expect(commentService.delete).not.toHaveBeenCalled(); + }); + + it('should deny when user is not owner and not space admin', async () => { + const otherUserComment = { ...mockComment, creatorId: 'other-user' }; + commentService.findById.mockResolvedValue(otherUserComment); + mockAbility.cannot.mockReturnValue(true); + + const result = await callTool('delete_comment', { commentId: 'comment-1' }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('You can only delete your own comments or must be a space admin'); + expect(commentService.delete).not.toHaveBeenCalled(); + }); + + it('should error when comment not found', async () => { + commentService.findById.mockRejectedValue(new NotFoundException('Comment not found')); + + const result = await callTool('delete_comment', { commentId: 'missing' }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('not found'); + }); + + it('should error when page not found', async () => { + pageRepo.findById.mockResolvedValue(null); + + const result = await callTool('delete_comment', { commentId: 'comment-1' }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('not found'); + }); + + it('should deny when comment is in different workspace', async () => { + commentService.findById.mockResolvedValue({ ...mockComment, workspaceId: 'other-ws' }); + + const result = await callTool('delete_comment', { commentId: 'comment-1' }); + + expect(result.isError).toBe(true); + expect(pageAccessService.validateCanEdit).not.toHaveBeenCalled(); + expect(commentService.delete).not.toHaveBeenCalled(); + }); + }); }); diff --git a/apps/server/src/integrations/mcp/tools/comment.tools.ts b/apps/server/src/integrations/mcp/tools/comment.tools.ts index 3fb4560be8..1153c1987d 100644 --- a/apps/server/src/integrations/mcp/tools/comment.tools.ts +++ b/apps/server/src/integrations/mcp/tools/comment.tools.ts @@ -4,6 +4,11 @@ import { z } from 'zod'; import { CommentService } from '../../../core/comment/comment.service'; import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { PageAccessService } from '../../../core/page/page-access/page-access.service'; +import SpaceAbilityFactory from '../../../core/casl/abilities/space-ability.factory'; +import { + SpaceCaslAction, + SpaceCaslSubject, +} from '../../../core/casl/interfaces/space-ability.type'; import { User, Workspace } from '@docmost/db/types/entity.types'; import { htmlToJson } from '../../../collaboration/collaboration.util'; @@ -72,6 +77,7 @@ export function registerCommentTools( commentService: CommentService, pageRepo: PageRepo, pageAccessService: PageAccessService, + spaceAbility: SpaceAbilityFactory, ) { // get_comments: Matches CommentController.findPageComments → pageAccessService.validateCanView server.tool( @@ -185,4 +191,52 @@ export function registerCommentTools( } }, ); + + // delete_comment: Matches CommentController.delete + // → pageAccessService.validateCanEdit + (isOwner OR spaceAdmin) + server.tool( + 'delete_comment', + 'Delete a comment', + { + commentId: z.string().describe('Comment ID'), + }, + async ({ commentId }) => { + try { + const comment = await commentService.findById(commentId); + + assertWorkspace(comment.workspaceId, workspace.id, 'Comment'); + + const page = await pageRepo.findById(comment.pageId); + if (!page) { + throw new NotFoundException('Page not found'); + } + + // Check page-level edit permission first + await pageAccessService.validateCanEdit(page, user); + + // Check if user is the comment owner + const isOwner = comment.creatorId === user.id; + + if (isOwner) { + await commentService.delete(comment.id); + } else { + // Space admin can delete any comment + const ability = await spaceAbility.createForUser(user, comment.spaceId); + if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) { + throw new ForbiddenException( + 'You can only delete your own comments or must be a space admin', + ); + } + await commentService.delete(comment.id); + } + + return textResult({ + message: 'Comment deleted successfully', + commentId: comment.id, + }); + } catch (error) { + return errorResult(error); + } + }, + ); } diff --git a/apps/server/src/integrations/mcp/tools/page.tools.spec.ts b/apps/server/src/integrations/mcp/tools/page.tools.spec.ts index 86e3b611f0..3955d3cda3 100644 --- a/apps/server/src/integrations/mcp/tools/page.tools.spec.ts +++ b/apps/server/src/integrations/mcp/tools/page.tools.spec.ts @@ -53,6 +53,7 @@ describe('Page Tools Authorization', () => { duplicatePage: jest.fn().mockResolvedValue({ id: 'dup-1', title: 'Copy', spaceId: 'space-1' }), movePage: jest.fn().mockResolvedValue(undefined), movePageToSpace: jest.fn().mockResolvedValue(undefined), + removePage: jest.fn().mockResolvedValue(undefined), }; pageRepo = { @@ -354,4 +355,44 @@ describe('Page Tools Authorization', () => { expect(pageService.movePageToSpace).not.toHaveBeenCalled(); }); }); + + describe('delete_page', () => { + it('should call validateCanEdit and removePage', async () => { + const result = await callTool('delete_page', { pageId: 'page-1' }); + + expect(pageAccessService.validateCanEdit).toHaveBeenCalledWith(mockPage, mockUser); + expect(pageService.removePage).toHaveBeenCalledWith('page-1', mockUser.id, mockWorkspace.id); + expect(result.isError).toBe(false); + }); + + it('should deny when validateCanEdit throws', async () => { + pageAccessService.validateCanEdit.mockRejectedValue(new ForbiddenException()); + + const result = await callTool('delete_page', { pageId: 'page-1' }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Permission denied'); + expect(pageService.removePage).not.toHaveBeenCalled(); + }); + + it('should error when page not found', async () => { + pageRepo.findById.mockResolvedValue(null); + + const result = await callTool('delete_page', { pageId: 'missing' }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('not found'); + expect(pageService.removePage).not.toHaveBeenCalled(); + }); + + it('should deny when page is in different workspace', async () => { + pageRepo.findById.mockResolvedValue({ ...mockPage, workspaceId: 'other-ws' }); + + const result = await callTool('delete_page', { pageId: 'page-1' }); + + expect(result.isError).toBe(true); + expect(pageAccessService.validateCanEdit).not.toHaveBeenCalled(); + expect(pageService.removePage).not.toHaveBeenCalled(); + }); + }); }); diff --git a/apps/server/src/integrations/mcp/tools/page.tools.ts b/apps/server/src/integrations/mcp/tools/page.tools.ts index 122f5e7919..57f46c967f 100644 --- a/apps/server/src/integrations/mcp/tools/page.tools.ts +++ b/apps/server/src/integrations/mcp/tools/page.tools.ts @@ -476,4 +476,38 @@ export function registerPageTools( } }, ); + + // delete_page: Matches PageController.delete (soft delete / trash) + // → pageAccessService.validateCanEdit + server.tool( + 'delete_page', + 'Move a page to trash (soft delete)', + { + pageId: z.string().describe('The page ID'), + }, + async ({ pageId }) => { + try { + const page = await pageRepo.findById(pageId); + + if (!page) { + throw new NotFoundException('Page not found'); + } + + assertWorkspace(page.workspaceId, workspace.id, 'Page'); + + // User needs edit permission to delete + await pageAccessService.validateCanEdit(page, user); + + await pageService.removePage(pageId, user.id, workspace.id); + + return textResult({ + message: 'Page moved to trash successfully', + pageId: page.id, + title: page.title, + }); + } catch (error) { + return errorResult(error); + } + }, + ); }