mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
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:
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user