Skip to content
This repository was archived by the owner on May 19, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/server/src/core/comment/comment.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,10 @@ export class CommentService {
return updatedComment;
}

async delete(commentId: string): Promise<void> {
await this.commentRepo.deleteComment(commentId);
}

private async queueCommentNotification(
content: any,
oldMentionIds: string[],
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/integrations/mcp/mcp-server.factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export class McpServerFactory {
this.commentService,
this.pageRepo,
this.pageAccessService,
this.spaceAbility,
);
registerOtherTools(
server,
Expand Down
85 changes: 85 additions & 0 deletions apps/server/src/integrations/mcp/tools/comment.tools.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ describe('Comment Tools Authorization', () => {
let commentService: Record<string, jest.Mock>;
let pageRepo: Record<string, jest.Mock>;
let pageAccessService: Record<string, jest.Mock>;
let spaceAbility: Record<string, jest.Mock>;
let mockAbility: { can: jest.Mock; cannot: jest.Mock };

beforeEach(() => {
toolHandlers.clear();
Expand All @@ -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 = {
Expand All @@ -53,13 +61,18 @@ describe('Comment Tools Authorization', () => {
validateCanEdit: jest.fn().mockResolvedValue({ hasRestriction: false }),
};

spaceAbility = {
createForUser: jest.fn().mockResolvedValue(mockAbility),
};

registerCommentTools(
mockServer as any,
mockUser,
mockWorkspace,
commentService as any,
pageRepo as any,
pageAccessService as any,
spaceAbility as any,
);
});

Expand Down Expand Up @@ -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();
});
});
});
54 changes: 54 additions & 0 deletions apps/server/src/integrations/mcp/tools/comment.tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -72,6 +77,7 @@ export function registerCommentTools(
commentService: CommentService,
pageRepo: PageRepo,
pageAccessService: PageAccessService,
spaceAbility: SpaceAbilityFactory,
) {
// get_comments: Matches CommentController.findPageComments → pageAccessService.validateCanView
server.tool(
Expand Down Expand Up @@ -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);
}
},
);
}
41 changes: 41 additions & 0 deletions apps/server/src/integrations/mcp/tools/page.tools.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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();
});
});
});
34 changes: 34 additions & 0 deletions apps/server/src/integrations/mcp/tools/page.tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
},
);
}
Loading