Add anchor text to MCP comment tool responses (#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>
This commit is contained in:
Copilot
2026-03-28 15:08:34 -04:00
committed by GitHub
parent df5dd0b98d
commit 27dc02aad1
3 changed files with 193 additions and 12 deletions
+13 -9
View File
@@ -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 {
+144
View File
@@ -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({
+36 -3
View File
@@ -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<string, CommentMark[]>();
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: [