Skip to content

Commit 27dc02a

Browse files
Copilottommoorclaude
authored
Add anchor text to MCP comment tool responses (outline#11886)
* Initial plan * Add comment anchor text to MCP comment tool responses Agent-Logs-Url: https://github.com/outline/outline/sessions/294b6510-996f-4a86-a7d6-7ed1c336fc19 Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com> * Address PR review: fix auth gap, cache marks, add anchorText tests - Always authorize read access in update_comment before exposing anchor text - Cache comment marks per document in list_comments to avoid O(n * docSize) - Add 4 MCP tests verifying anchorText presence/absence in responses Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com> Co-authored-by: Tom Moor <tom@getoutline.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent df5dd0b commit 27dc02a

3 files changed

Lines changed: 193 additions & 12 deletions

File tree

server/presenters/comment.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,31 @@
1-
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
1+
import {
2+
ProsemirrorHelper,
3+
type CommentMark,
4+
} from "@shared/utils/ProsemirrorHelper";
25
import type { Comment } from "@server/models";
36
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
47
import presentUser from "./user";
58

69
type Options = {
710
/** Whether to include anchor text, if it exists */
811
includeAnchorText?: boolean;
12+
/** Precomputed comment marks to avoid reparsing the document. */
13+
commentMarks?: CommentMark[];
914
};
1015

1116
export default function present(
1217
comment: Comment,
13-
{ includeAnchorText }: Options = {}
18+
{ includeAnchorText, commentMarks }: Options = {}
1419
) {
1520
let anchorText: string | undefined;
1621

1722
if (includeAnchorText && comment.document) {
18-
const commentMarks = ProsemirrorHelper.getComments(
19-
DocumentHelper.toProsemirror(comment.document)
20-
);
21-
anchorText = ProsemirrorHelper.getAnchorTextForComment(
22-
commentMarks,
23-
comment.id
24-
);
23+
const marks =
24+
commentMarks ??
25+
ProsemirrorHelper.getComments(
26+
DocumentHelper.toProsemirror(comment.document)
27+
);
28+
anchorText = ProsemirrorHelper.getAnchorTextForComment(marks, comment.id);
2529
}
2630

2731
return {

server/routes/mcp/index.test.ts

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { Scope, TeamPreference } from "@shared/types";
2+
import type { ProsemirrorData } from "@shared/types";
23
import {
34
buildUser,
45
buildAdmin,
56
buildCollection,
67
buildDocument,
78
buildComment,
9+
buildCommentMark,
810
buildOAuthAuthentication,
911
} from "@server/test/factories";
1012
import { getTestServer } from "@server/test/support";
@@ -620,6 +622,148 @@ describe("POST /mcp/", () => {
620622
expect(data.text).toContain("Updated comment text");
621623
});
622624

625+
it("list_comments includes anchorText when comment is anchored", async () => {
626+
const { user, accessToken } = await buildOAuthUser();
627+
const collection = await buildCollection({
628+
teamId: user.teamId,
629+
userId: user.id,
630+
});
631+
const document = await buildDocument({
632+
teamId: user.teamId,
633+
userId: user.id,
634+
collectionId: collection.id,
635+
});
636+
const comment = await buildComment({
637+
userId: user.id,
638+
documentId: document.id,
639+
});
640+
641+
const anchorText = "highlighted text";
642+
const content = {
643+
type: "doc",
644+
content: [
645+
{
646+
type: "paragraph",
647+
content: [
648+
{
649+
type: "text",
650+
text: anchorText,
651+
marks: [buildCommentMark({ id: comment.id, userId: user.id })],
652+
},
653+
],
654+
},
655+
],
656+
} as ProsemirrorData;
657+
await document.update({ content });
658+
659+
const res = await callMcpTool(server, accessToken, "list_comments", {
660+
documentId: document.id,
661+
});
662+
const data = (res?.result?.content ?? []).map((c: { text: string }) =>
663+
JSON.parse(c.text)
664+
);
665+
666+
const match = data.find((c: { id: string }) => c.id === comment.id) as {
667+
anchorText: string;
668+
};
669+
expect(match).toBeDefined();
670+
expect(match.anchorText).toEqual(anchorText);
671+
});
672+
673+
it("list_comments returns undefined anchorText for non-anchored comment", async () => {
674+
const { user, accessToken } = await buildOAuthUser();
675+
const collection = await buildCollection({
676+
teamId: user.teamId,
677+
userId: user.id,
678+
});
679+
const document = await buildDocument({
680+
teamId: user.teamId,
681+
userId: user.id,
682+
collectionId: collection.id,
683+
});
684+
await buildComment({
685+
userId: user.id,
686+
documentId: document.id,
687+
});
688+
689+
const res = await callMcpTool(server, accessToken, "list_comments", {
690+
documentId: document.id,
691+
});
692+
const data = (res?.result?.content ?? []).map((c: { text: string }) =>
693+
JSON.parse(c.text)
694+
);
695+
696+
expect(data.length).toBeGreaterThanOrEqual(1);
697+
expect(data[0].anchorText).toBeUndefined();
698+
});
699+
700+
it("create_comment includes anchorText in response", async () => {
701+
const { user, accessToken } = await buildOAuthUser();
702+
const collection = await buildCollection({
703+
teamId: user.teamId,
704+
userId: user.id,
705+
});
706+
const document = await buildDocument({
707+
teamId: user.teamId,
708+
userId: user.id,
709+
collectionId: collection.id,
710+
});
711+
712+
const res = await callMcpTool(server, accessToken, "create_comment", {
713+
documentId: document.id,
714+
text: "A new comment",
715+
});
716+
const data = JSON.parse(res?.result?.content?.[0]?.text ?? "{}");
717+
718+
// New comments have no anchor mark in the document, so anchorText is undefined
719+
expect(data.id).toBeDefined();
720+
expect(data.anchorText).toBeUndefined();
721+
});
722+
723+
it("update_comment includes anchorText in response", async () => {
724+
const { user, accessToken } = await buildOAuthUser();
725+
const collection = await buildCollection({
726+
teamId: user.teamId,
727+
userId: user.id,
728+
});
729+
const document = await buildDocument({
730+
teamId: user.teamId,
731+
userId: user.id,
732+
collectionId: collection.id,
733+
});
734+
const comment = await buildComment({
735+
userId: user.id,
736+
documentId: document.id,
737+
});
738+
739+
const anchorText = "anchored content";
740+
const content = {
741+
type: "doc",
742+
content: [
743+
{
744+
type: "paragraph",
745+
content: [
746+
{
747+
type: "text",
748+
text: anchorText,
749+
marks: [buildCommentMark({ id: comment.id, userId: user.id })],
750+
},
751+
],
752+
},
753+
],
754+
} as ProsemirrorData;
755+
await document.update({ content });
756+
757+
const res = await callMcpTool(server, accessToken, "update_comment", {
758+
id: comment.id,
759+
text: "Updated text",
760+
});
761+
const data = JSON.parse(res?.result?.content?.[0]?.text ?? "{}");
762+
763+
expect(data.id).toEqual(comment.id);
764+
expect(data.anchorText).toEqual(anchorText);
765+
});
766+
623767
it("delete_comment deletes own comment", async () => {
624768
const { user, accessToken } = await buildOAuthUser();
625769
const collection = await buildCollection({

server/tools/comments.ts

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import type { FindOptions, WhereOptions } from "sequelize";
44
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
55
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
66
import { CommentStatusFilter } from "@shared/types";
7+
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
8+
import type { CommentMark } from "@shared/utils/ProsemirrorHelper";
79
import { commentParser } from "@server/editor";
10+
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
811
import { Comment, Collection, Document } from "@server/models";
912
import { authorize } from "@server/policies";
1013
import { presentComment } from "@server/presenters";
@@ -23,10 +26,17 @@ import {
2326
* ProseMirror JSON.
2427
*
2528
* @param comment - the comment model instance.
29+
* @param commentMarks - optional precomputed comment marks to avoid reparsing.
2630
* @returns the presented comment with an additional `text` field.
2731
*/
28-
function presentCommentWithText(comment: Comment) {
29-
const presented = presentComment(comment);
32+
function presentCommentWithText(
33+
comment: Comment,
34+
commentMarks?: CommentMark[]
35+
) {
36+
const presented = presentComment(comment, {
37+
includeAnchorText: true,
38+
commentMarks,
39+
});
3040
return {
3141
...presented,
3242
text: comment.toPlainText(),
@@ -182,7 +192,25 @@ export function commentTools(server: McpServer, scopes: string[]) {
182192
});
183193
}
184194

185-
const presented = comments.map(presentCommentWithText);
195+
// Precompute comment marks per document to avoid reparsing
196+
// the same document for every comment.
197+
const marksCache = new Map<string, CommentMark[]>();
198+
const presented = comments.map((comment) => {
199+
const doc = comment.document;
200+
let marks: CommentMark[] | undefined;
201+
if (doc) {
202+
if (!marksCache.has(doc.id)) {
203+
marksCache.set(
204+
doc.id,
205+
ProsemirrorHelper.getComments(
206+
DocumentHelper.toProsemirror(doc)
207+
)
208+
);
209+
}
210+
marks = marksCache.get(doc.id);
211+
}
212+
return presentCommentWithText(comment, marks);
213+
});
186214
return success(presented);
187215
} catch (err) {
188216
return error(err);
@@ -238,6 +266,7 @@ export function commentTools(server: McpServer, scopes: string[]) {
238266
});
239267

240268
comment.createdBy = user;
269+
comment.document = document!;
241270

242271
const presented = presentCommentWithText(comment);
243272
return {
@@ -292,6 +321,9 @@ export function commentTools(server: McpServer, scopes: string[]) {
292321
userId: user.id,
293322
});
294323

324+
authorize(user, "read", comment);
325+
authorize(user, "read", document);
326+
295327
if (text !== undefined) {
296328
authorize(user, "update", comment);
297329
authorize(user, "comment", document);
@@ -312,6 +344,7 @@ export function commentTools(server: McpServer, scopes: string[]) {
312344

313345
await comment.saveWithCtx(ctx, status ? { silent: true } : undefined);
314346

347+
comment.document = document!;
315348
const presented = presentCommentWithText(comment);
316349
return {
317350
content: [

0 commit comments

Comments
 (0)