diff --git a/server/presenters/comment.ts b/server/presenters/comment.ts index 86a4b8a371..18daa8caaf 100644 --- a/server/presenters/comment.ts +++ b/server/presenters/comment.ts @@ -1,4 +1,7 @@ -import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper"; +import { + ProsemirrorHelper, + type CommentMark, +} from "@shared/utils/ProsemirrorHelper"; import type { Comment } from "@server/models"; import { DocumentHelper } from "@server/models/helpers/DocumentHelper"; import presentUser from "./user"; @@ -6,22 +9,23 @@ import presentUser from "./user"; type Options = { /** Whether to include anchor text, if it exists */ includeAnchorText?: boolean; + /** Precomputed comment marks to avoid reparsing the document. */ + commentMarks?: CommentMark[]; }; export default function present( comment: Comment, - { includeAnchorText }: Options = {} + { includeAnchorText, commentMarks }: Options = {} ) { let anchorText: string | undefined; if (includeAnchorText && comment.document) { - const commentMarks = ProsemirrorHelper.getComments( - DocumentHelper.toProsemirror(comment.document) - ); - anchorText = ProsemirrorHelper.getAnchorTextForComment( - commentMarks, - comment.id - ); + const marks = + commentMarks ?? + ProsemirrorHelper.getComments( + DocumentHelper.toProsemirror(comment.document) + ); + anchorText = ProsemirrorHelper.getAnchorTextForComment(marks, comment.id); } return { diff --git a/server/routes/mcp/index.test.ts b/server/routes/mcp/index.test.ts index 09d48068ca..782a832a0d 100644 --- a/server/routes/mcp/index.test.ts +++ b/server/routes/mcp/index.test.ts @@ -1,10 +1,12 @@ import { Scope, TeamPreference } from "@shared/types"; +import type { ProsemirrorData } from "@shared/types"; import { buildUser, buildAdmin, buildCollection, buildDocument, buildComment, + buildCommentMark, buildOAuthAuthentication, } from "@server/test/factories"; import { getTestServer } from "@server/test/support"; @@ -620,6 +622,148 @@ describe("POST /mcp/", () => { expect(data.text).toContain("Updated comment text"); }); + it("list_comments includes anchorText when comment is anchored", async () => { + const { user, accessToken } = await buildOAuthUser(); + const collection = await buildCollection({ + teamId: user.teamId, + userId: user.id, + }); + const document = await buildDocument({ + teamId: user.teamId, + userId: user.id, + collectionId: collection.id, + }); + const comment = await buildComment({ + userId: user.id, + documentId: document.id, + }); + + const anchorText = "highlighted text"; + const content = { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: anchorText, + marks: [buildCommentMark({ id: comment.id, userId: user.id })], + }, + ], + }, + ], + } as ProsemirrorData; + await document.update({ content }); + + const res = await callMcpTool(server, accessToken, "list_comments", { + documentId: document.id, + }); + const data = (res?.result?.content ?? []).map((c: { text: string }) => + JSON.parse(c.text) + ); + + const match = data.find((c: { id: string }) => c.id === comment.id) as { + anchorText: string; + }; + expect(match).toBeDefined(); + expect(match.anchorText).toEqual(anchorText); + }); + + it("list_comments returns undefined anchorText for non-anchored comment", async () => { + const { user, accessToken } = await buildOAuthUser(); + const collection = await buildCollection({ + teamId: user.teamId, + userId: user.id, + }); + const document = await buildDocument({ + teamId: user.teamId, + userId: user.id, + collectionId: collection.id, + }); + await buildComment({ + userId: user.id, + documentId: document.id, + }); + + const res = await callMcpTool(server, accessToken, "list_comments", { + documentId: document.id, + }); + const data = (res?.result?.content ?? []).map((c: { text: string }) => + JSON.parse(c.text) + ); + + expect(data.length).toBeGreaterThanOrEqual(1); + expect(data[0].anchorText).toBeUndefined(); + }); + + it("create_comment includes anchorText in response", async () => { + const { user, accessToken } = await buildOAuthUser(); + const collection = await buildCollection({ + teamId: user.teamId, + userId: user.id, + }); + const document = await buildDocument({ + teamId: user.teamId, + userId: user.id, + collectionId: collection.id, + }); + + const res = await callMcpTool(server, accessToken, "create_comment", { + documentId: document.id, + text: "A new comment", + }); + const data = JSON.parse(res?.result?.content?.[0]?.text ?? "{}"); + + // New comments have no anchor mark in the document, so anchorText is undefined + expect(data.id).toBeDefined(); + expect(data.anchorText).toBeUndefined(); + }); + + it("update_comment includes anchorText in response", async () => { + const { user, accessToken } = await buildOAuthUser(); + const collection = await buildCollection({ + teamId: user.teamId, + userId: user.id, + }); + const document = await buildDocument({ + teamId: user.teamId, + userId: user.id, + collectionId: collection.id, + }); + const comment = await buildComment({ + userId: user.id, + documentId: document.id, + }); + + const anchorText = "anchored content"; + const content = { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: anchorText, + marks: [buildCommentMark({ id: comment.id, userId: user.id })], + }, + ], + }, + ], + } as ProsemirrorData; + await document.update({ content }); + + const res = await callMcpTool(server, accessToken, "update_comment", { + id: comment.id, + text: "Updated text", + }); + const data = JSON.parse(res?.result?.content?.[0]?.text ?? "{}"); + + expect(data.id).toEqual(comment.id); + expect(data.anchorText).toEqual(anchorText); + }); + it("delete_comment deletes own comment", async () => { const { user, accessToken } = await buildOAuthUser(); const collection = await buildCollection({ diff --git a/server/tools/comments.ts b/server/tools/comments.ts index 5fd03814e5..4056598933 100644 --- a/server/tools/comments.ts +++ b/server/tools/comments.ts @@ -4,7 +4,10 @@ import type { FindOptions, WhereOptions } from "sequelize"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { CommentStatusFilter } from "@shared/types"; +import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper"; +import type { CommentMark } from "@shared/utils/ProsemirrorHelper"; import { commentParser } from "@server/editor"; +import { DocumentHelper } from "@server/models/helpers/DocumentHelper"; import { Comment, Collection, Document } from "@server/models"; import { authorize } from "@server/policies"; import { presentComment } from "@server/presenters"; @@ -23,10 +26,17 @@ import { * ProseMirror JSON. * * @param comment - the comment model instance. + * @param commentMarks - optional precomputed comment marks to avoid reparsing. * @returns the presented comment with an additional `text` field. */ -function presentCommentWithText(comment: Comment) { - const presented = presentComment(comment); +function presentCommentWithText( + comment: Comment, + commentMarks?: CommentMark[] +) { + const presented = presentComment(comment, { + includeAnchorText: true, + commentMarks, + }); return { ...presented, text: comment.toPlainText(), @@ -182,7 +192,25 @@ export function commentTools(server: McpServer, scopes: string[]) { }); } - const presented = comments.map(presentCommentWithText); + // Precompute comment marks per document to avoid reparsing + // the same document for every comment. + const marksCache = new Map(); + const presented = comments.map((comment) => { + const doc = comment.document; + let marks: CommentMark[] | undefined; + if (doc) { + if (!marksCache.has(doc.id)) { + marksCache.set( + doc.id, + ProsemirrorHelper.getComments( + DocumentHelper.toProsemirror(doc) + ) + ); + } + marks = marksCache.get(doc.id); + } + return presentCommentWithText(comment, marks); + }); return success(presented); } catch (err) { return error(err); @@ -238,6 +266,7 @@ export function commentTools(server: McpServer, scopes: string[]) { }); comment.createdBy = user; + comment.document = document!; const presented = presentCommentWithText(comment); return { @@ -292,6 +321,9 @@ export function commentTools(server: McpServer, scopes: string[]) { userId: user.id, }); + authorize(user, "read", comment); + authorize(user, "read", document); + if (text !== undefined) { authorize(user, "update", comment); authorize(user, "comment", document); @@ -312,6 +344,7 @@ export function commentTools(server: McpServer, scopes: string[]) { await comment.saveWithCtx(ctx, status ? { silent: true } : undefined); + comment.document = document!; const presented = presentCommentWithText(comment); return { content: [