mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
Refactor MCP tests (#12347)
This commit is contained in:
@@ -1,40 +1,23 @@
|
||||
import { Scope, TeamPreference } from "@shared/types";
|
||||
import type { ProsemirrorData } from "@shared/types";
|
||||
import { Attachment } from "@server/models";
|
||||
import { UserFlag } from "@server/models/User";
|
||||
import {
|
||||
buildUser,
|
||||
buildAdmin,
|
||||
buildCollection,
|
||||
buildDocument,
|
||||
buildComment,
|
||||
buildCommentMark,
|
||||
buildOAuthAuthentication,
|
||||
} from "@server/test/factories";
|
||||
import { getTestServer } from "@server/test/support";
|
||||
import {
|
||||
buildOAuthUser,
|
||||
callMcpTool,
|
||||
mcpHeaders,
|
||||
mcpRequest,
|
||||
parseMcpResponse,
|
||||
callMcpTool,
|
||||
} from "@server/test/McpHelper";
|
||||
|
||||
const server = getTestServer();
|
||||
|
||||
async function buildOAuthUser(
|
||||
overrides: { teamId?: string; role?: string } = {}
|
||||
) {
|
||||
const user =
|
||||
overrides.role === "admin"
|
||||
? await buildAdmin(overrides.teamId ? { teamId: overrides.teamId } : {})
|
||||
: await buildUser(overrides.teamId ? { teamId: overrides.teamId } : {});
|
||||
const auth = await buildOAuthAuthentication({
|
||||
user,
|
||||
scope: [Scope.Read, Scope.Write, Scope.Create],
|
||||
});
|
||||
return { user, accessToken: auth.accessToken! };
|
||||
}
|
||||
|
||||
describe("POST /mcp/", () => {
|
||||
describe("protocol", () => {
|
||||
it("should require authentication", async () => {
|
||||
@@ -134,807 +117,6 @@ describe("POST /mcp/", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("collection tools", () => {
|
||||
it("list_collections returns user collections", async () => {
|
||||
const { user, accessToken } = await buildOAuthUser();
|
||||
const collection = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
const res = await callMcpTool(server, accessToken, "list_collections");
|
||||
const data = (res?.result?.content ?? []).map((c: { text: string }) =>
|
||||
JSON.parse(c.text)
|
||||
);
|
||||
|
||||
expect(data.length).toBeGreaterThanOrEqual(1);
|
||||
const ids = data.map((c: { id: string }) => c.id);
|
||||
expect(ids).toContain(collection.id);
|
||||
|
||||
const match = data.find(
|
||||
(c: { id: string }) => c.id === collection.id
|
||||
) as { url: string };
|
||||
expect(match.url).toMatch(/^https?:\/\//);
|
||||
});
|
||||
|
||||
it("list_collections does not return collections from another team", async () => {
|
||||
const { accessToken } = await buildOAuthUser();
|
||||
const otherUser = await buildUser();
|
||||
const otherCollection = await buildCollection({
|
||||
teamId: otherUser.teamId,
|
||||
userId: otherUser.id,
|
||||
});
|
||||
|
||||
const res = await callMcpTool(server, accessToken, "list_collections");
|
||||
const data = (res?.result?.content ?? []).map((c: { text: string }) =>
|
||||
JSON.parse(c.text)
|
||||
);
|
||||
|
||||
const ids = data.map((c: { id: string }) => c.id);
|
||||
expect(ids).not.toContain(otherCollection.id);
|
||||
});
|
||||
|
||||
it("create_collection creates with name, description, icon, color", async () => {
|
||||
const { accessToken } = await buildOAuthUser();
|
||||
|
||||
const res = await callMcpTool(server, accessToken, "create_collection", {
|
||||
name: "Test Collection",
|
||||
description: "A test description",
|
||||
icon: "rocket",
|
||||
color: "#FF0000",
|
||||
});
|
||||
const data = JSON.parse(res?.result?.content?.[0]?.text ?? "{}");
|
||||
|
||||
expect(data.name).toEqual("Test Collection");
|
||||
expect(data.icon).toEqual("rocket");
|
||||
expect(data.color).toEqual("#FF0000");
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.url).toMatch(/^https?:\/\//);
|
||||
});
|
||||
|
||||
it("update_collection updates fields on existing collection", async () => {
|
||||
const { user, accessToken } = await buildOAuthUser();
|
||||
const collection = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
const res = await callMcpTool(server, accessToken, "update_collection", {
|
||||
id: collection.id,
|
||||
name: "Updated Name",
|
||||
description: "Updated description",
|
||||
});
|
||||
const data = JSON.parse(res?.result?.content?.[0]?.text ?? "{}");
|
||||
|
||||
expect(data.name).toEqual("Updated Name");
|
||||
expect(data.url).toMatch(/^https?:\/\//);
|
||||
});
|
||||
|
||||
it("fetch collection returns collection details", async () => {
|
||||
const { user, accessToken } = await buildOAuthUser();
|
||||
const collection = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
const res = await callMcpTool(server, accessToken, "fetch", {
|
||||
resource: "collection",
|
||||
id: collection.id,
|
||||
});
|
||||
|
||||
expect(res?.result?.content).toBeDefined();
|
||||
expect(res!.result!.content!.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const data = JSON.parse(res!.result!.content![0].text ?? "{}");
|
||||
expect(data.id).toEqual(collection.id);
|
||||
expect(data.url).toMatch(/^https?:\/\//);
|
||||
});
|
||||
});
|
||||
|
||||
describe("document tools", () => {
|
||||
it("list_documents returns recent documents", 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, "list_documents");
|
||||
const data = (res?.result?.content ?? []).map((c: { text: string }) =>
|
||||
JSON.parse(c.text)
|
||||
);
|
||||
|
||||
const ids = data.map((d: { document: { id: string } }) => d.document.id);
|
||||
expect(ids).toContain(document.id);
|
||||
|
||||
const match = data.find(
|
||||
(d: { document: { id: string } }) => d.document.id === document.id
|
||||
) as { document: { url: string } };
|
||||
expect(match.document.url).toMatch(/^https?:\/\//);
|
||||
});
|
||||
|
||||
it("list_documents filters by collection", async () => {
|
||||
const { user, accessToken } = await buildOAuthUser();
|
||||
const collection1 = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
const collection2 = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
const doc1 = await buildDocument({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
collectionId: collection1.id,
|
||||
});
|
||||
await buildDocument({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
collectionId: collection2.id,
|
||||
});
|
||||
|
||||
const res = await callMcpTool(server, accessToken, "list_documents", {
|
||||
collectionId: collection1.id,
|
||||
});
|
||||
const data = (res?.result?.content ?? []).map((c: { text: string }) =>
|
||||
JSON.parse(c.text)
|
||||
);
|
||||
|
||||
const ids = data.map((d: { document: { id: string } }) => d.document.id);
|
||||
expect(ids).toContain(doc1.id);
|
||||
expect(
|
||||
data.every(
|
||||
(d: { document: { collectionId: string } }) =>
|
||||
d.document.collectionId === collection1.id
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("create_document creates in a collection", async () => {
|
||||
const { user, accessToken } = await buildOAuthUser();
|
||||
const collection = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
const res = await callMcpTool(server, accessToken, "create_document", {
|
||||
title: "New Document",
|
||||
text: "Hello world",
|
||||
collectionId: collection.id,
|
||||
});
|
||||
const data = JSON.parse(res?.result?.content?.[0]?.text ?? "{}");
|
||||
|
||||
expect(data.document.title).toEqual("New Document");
|
||||
expect(data.document.collectionId).toEqual(collection.id);
|
||||
expect(data.document.id).toBeDefined();
|
||||
expect(data.document.url).toMatch(/^https?:\/\//);
|
||||
});
|
||||
|
||||
it("create_document creates nested under parent document", async () => {
|
||||
const { user, accessToken } = await buildOAuthUser();
|
||||
const collection = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
const parent = await buildDocument({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
collectionId: collection.id,
|
||||
});
|
||||
|
||||
const res = await callMcpTool(server, accessToken, "create_document", {
|
||||
title: "Child Document",
|
||||
text: "Nested content",
|
||||
parentDocumentId: parent.id,
|
||||
});
|
||||
const data = JSON.parse(res?.result?.content?.[0]?.text ?? "{}");
|
||||
|
||||
expect(data.document.title).toEqual("Child Document");
|
||||
expect(data.document.parentDocumentId).toEqual(parent.id);
|
||||
});
|
||||
|
||||
it("update_document updates title and text", 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, "update_document", {
|
||||
id: document.id,
|
||||
title: "Updated Title",
|
||||
text: "Updated content",
|
||||
});
|
||||
const data = JSON.parse(res?.result?.content?.[0]?.text ?? "{}");
|
||||
|
||||
expect(data.document.title).toEqual("Updated Title");
|
||||
expect(data.document.url).toMatch(/^https?:\/\//);
|
||||
});
|
||||
|
||||
it("update_document unpublishes a document", 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, "update_document", {
|
||||
id: document.id,
|
||||
publish: false,
|
||||
});
|
||||
const data = JSON.parse(res?.result?.content?.[0]?.text ?? "{}");
|
||||
|
||||
expect(data.document.id).toEqual(document.id);
|
||||
expect(res?.result?.isError).toBeUndefined();
|
||||
});
|
||||
|
||||
it("update_document fails to unpublish a document with children", 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 buildDocument({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
collectionId: collection.id,
|
||||
parentDocumentId: document.id,
|
||||
});
|
||||
|
||||
const res = await callMcpTool(server, accessToken, "update_document", {
|
||||
id: document.id,
|
||||
publish: false,
|
||||
});
|
||||
|
||||
expect(res?.result?.isError).toBe(true);
|
||||
});
|
||||
|
||||
it("move_document moves to a different collection", async () => {
|
||||
const { user, accessToken } = await buildOAuthUser();
|
||||
const collection1 = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
const collection2 = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
const document = await buildDocument({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
collectionId: collection1.id,
|
||||
});
|
||||
|
||||
const res = await callMcpTool(server, accessToken, "move_document", {
|
||||
id: document.id,
|
||||
collectionId: collection2.id,
|
||||
});
|
||||
const data = (res?.result?.content ?? []).map((c: { text: string }) =>
|
||||
JSON.parse(c.text)
|
||||
);
|
||||
|
||||
expect(res?.result?.isError).toBeUndefined();
|
||||
const moved = data.find(
|
||||
(d: { document: { id: string } }) => d.document.id === document.id
|
||||
) as { document: { collectionId: string } };
|
||||
expect(moved).toBeDefined();
|
||||
expect(moved.document.collectionId).toEqual(collection2.id);
|
||||
});
|
||||
|
||||
it("move_document moves under a parent document", async () => {
|
||||
const { user, accessToken } = await buildOAuthUser();
|
||||
const collection = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
const parent = await buildDocument({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
collectionId: collection.id,
|
||||
});
|
||||
const child = await buildDocument({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
collectionId: collection.id,
|
||||
});
|
||||
|
||||
const res = await callMcpTool(server, accessToken, "move_document", {
|
||||
id: child.id,
|
||||
parentDocumentId: parent.id,
|
||||
});
|
||||
const data = (res?.result?.content ?? []).map((c: { text: string }) =>
|
||||
JSON.parse(c.text)
|
||||
);
|
||||
|
||||
expect(res?.result?.isError).toBeUndefined();
|
||||
const moved = data.find(
|
||||
(d: { document: { id: string } }) => d.document.id === child.id
|
||||
) as { document: { parentDocumentId: string } };
|
||||
expect(moved).toBeDefined();
|
||||
expect(moved.document.parentDocumentId).toEqual(parent.id);
|
||||
});
|
||||
|
||||
it("move_document fails without collectionId or parentDocumentId", 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, "move_document", {
|
||||
id: document.id,
|
||||
});
|
||||
|
||||
expect(res?.result?.isError).toBe(true);
|
||||
});
|
||||
|
||||
it("move_document fails when nesting a document inside itself", 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, "move_document", {
|
||||
id: document.id,
|
||||
parentDocumentId: document.id,
|
||||
});
|
||||
|
||||
expect(res?.result?.isError).toBe(true);
|
||||
});
|
||||
|
||||
it("fetch document returns metadata and markdown", 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,
|
||||
text: "# Hello\n\nWorld",
|
||||
});
|
||||
|
||||
const res = await callMcpTool(server, accessToken, "fetch", {
|
||||
resource: "document",
|
||||
id: document.id,
|
||||
});
|
||||
|
||||
expect(res?.result?.content).toBeDefined();
|
||||
expect(res!.result!.content!.length).toEqual(2);
|
||||
|
||||
// First content is JSON metadata
|
||||
const metadata = JSON.parse(res!.result!.content![0].text ?? "{}");
|
||||
expect(metadata.document.id).toEqual(document.id);
|
||||
expect(metadata.document.title).toEqual(document.title);
|
||||
expect(metadata.document.url).toMatch(/^https?:\/\//);
|
||||
|
||||
// Second content is markdown text
|
||||
expect(res!.result!.content![1].text).toContain("Hello");
|
||||
});
|
||||
});
|
||||
|
||||
describe("comment tools", () => {
|
||||
it("list_comments returns comments on a document", 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 res = await callMcpTool(server, accessToken, "list_comments", {
|
||||
documentId: document.id,
|
||||
});
|
||||
const data = (res?.result?.content ?? []).map((c: { text: string }) =>
|
||||
JSON.parse(c.text)
|
||||
);
|
||||
|
||||
const ids = data.map((c: { id: string }) => c.id);
|
||||
expect(ids).toContain(comment.id);
|
||||
});
|
||||
|
||||
it("create_comment creates a comment on a document", 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: "This is a test comment",
|
||||
});
|
||||
const data = JSON.parse(res?.result?.content?.[0]?.text ?? "{}");
|
||||
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.documentId).toEqual(document.id);
|
||||
});
|
||||
|
||||
it("create_comment creates a reply to an existing 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,
|
||||
});
|
||||
const parentComment = await buildComment({
|
||||
userId: user.id,
|
||||
documentId: document.id,
|
||||
});
|
||||
|
||||
const res = await callMcpTool(server, accessToken, "create_comment", {
|
||||
documentId: document.id,
|
||||
text: "This is a reply",
|
||||
parentCommentId: parentComment.id,
|
||||
});
|
||||
const data = JSON.parse(res?.result?.content?.[0]?.text ?? "{}");
|
||||
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.parentCommentId).toEqual(parentComment.id);
|
||||
});
|
||||
|
||||
it("update_comment updates text", 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 res = await callMcpTool(server, accessToken, "update_comment", {
|
||||
id: comment.id,
|
||||
text: "Updated comment text",
|
||||
});
|
||||
const data = JSON.parse(res?.result?.content?.[0]?.text ?? "{}");
|
||||
|
||||
expect(data.id).toEqual(comment.id);
|
||||
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({
|
||||
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 res = await callMcpTool(server, accessToken, "delete_comment", {
|
||||
id: comment.id,
|
||||
});
|
||||
const data = JSON.parse(res?.result?.content?.[0]?.text ?? "{}");
|
||||
|
||||
expect(data.success).toBe(true);
|
||||
});
|
||||
|
||||
it("delete_comment fails for non-author non-admin", async () => {
|
||||
const { user } = 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,
|
||||
});
|
||||
|
||||
// Create a different non-admin user on the same team
|
||||
const otherUser = await buildUser({ teamId: user.teamId });
|
||||
const otherAuth = await buildOAuthAuthentication({
|
||||
user: otherUser,
|
||||
scope: [Scope.Read, Scope.Write, Scope.Create],
|
||||
});
|
||||
|
||||
const res = await callMcpTool(
|
||||
server,
|
||||
otherAuth.accessToken!,
|
||||
"delete_comment",
|
||||
{ id: comment.id }
|
||||
);
|
||||
|
||||
expect(res?.result?.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("attachment tools", () => {
|
||||
it("create_attachment returns absolute uploadUrl and proxied attachment url", async () => {
|
||||
const { accessToken } = await buildOAuthUser();
|
||||
const res = await callMcpTool(server, accessToken, "create_attachment", {
|
||||
contentType: "image/png",
|
||||
name: "test.png",
|
||||
size: 1000,
|
||||
});
|
||||
|
||||
expect(res?.result?.isError).toBeFalsy();
|
||||
const data = JSON.parse(res?.result?.content?.[0]?.text ?? "{}");
|
||||
|
||||
expect(data.uploadUrl).toMatch(/^https?:\/\//);
|
||||
expect(data.attachment.url).toMatch(/^https?:\/\//);
|
||||
expect(data.attachment.url).toContain("/api/attachments.redirect?id=");
|
||||
expect(data.curlCommand).toContain(data.uploadUrl);
|
||||
});
|
||||
|
||||
it("create_attachment persists attachment record", async () => {
|
||||
const { user, accessToken } = await buildOAuthUser();
|
||||
const res = await callMcpTool(server, accessToken, "create_attachment", {
|
||||
contentType: "image/png",
|
||||
name: "test.png",
|
||||
size: 1000,
|
||||
});
|
||||
|
||||
const data = JSON.parse(res?.result?.content?.[0]?.text ?? "{}");
|
||||
const attachment = await Attachment.findByPk(data.attachment.id, {
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
expect(Number(attachment.size)).toEqual(1000);
|
||||
expect(attachment.contentType).toEqual("image/png");
|
||||
expect(attachment.userId).toEqual(user.id);
|
||||
expect(attachment.teamId).toEqual(user.teamId);
|
||||
});
|
||||
|
||||
it("create_attachment rejects size larger than max", async () => {
|
||||
const { accessToken } = await buildOAuthUser();
|
||||
const res = await callMcpTool(server, accessToken, "create_attachment", {
|
||||
contentType: "image/png",
|
||||
name: "huge.png",
|
||||
size: 10_000_000_000,
|
||||
});
|
||||
expect(res?.result?.isError).toBe(true);
|
||||
});
|
||||
|
||||
it("create_attachment rejects negative size", async () => {
|
||||
const { accessToken } = await buildOAuthUser();
|
||||
const res = await callMcpTool(server, accessToken, "create_attachment", {
|
||||
contentType: "image/png",
|
||||
name: "neg.png",
|
||||
size: -1,
|
||||
});
|
||||
expect(res?.error ?? res?.result?.isError).toBeTruthy();
|
||||
});
|
||||
|
||||
it("create_attachment rejects fractional size", async () => {
|
||||
const { accessToken } = await buildOAuthUser();
|
||||
const res = await callMcpTool(server, accessToken, "create_attachment", {
|
||||
contentType: "image/png",
|
||||
name: "frac.png",
|
||||
size: 1.5,
|
||||
});
|
||||
expect(res?.error ?? res?.result?.isError).toBeTruthy();
|
||||
});
|
||||
|
||||
it("read-only token does not have create_attachment tool", async () => {
|
||||
const user = await buildUser();
|
||||
const auth = await buildOAuthAuthentication({
|
||||
user,
|
||||
scope: [Scope.Read],
|
||||
});
|
||||
const res = await callMcpTool(
|
||||
server,
|
||||
auth.accessToken!,
|
||||
"create_attachment",
|
||||
{
|
||||
contentType: "image/png",
|
||||
name: "test.png",
|
||||
size: 1000,
|
||||
}
|
||||
);
|
||||
expect(res?.result?.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scope enforcement", () => {
|
||||
async function buildScopedOAuthUser(scope: Scope[]) {
|
||||
const user = await buildUser();
|
||||
|
||||
@@ -1,8 +1,31 @@
|
||||
// eslint-disable no-restricted-imports
|
||||
import type { Response } from "node-fetch";
|
||||
import { Scope } from "@shared/types";
|
||||
import { buildAdmin, buildOAuthAuthentication, buildUser } from "./factories";
|
||||
|
||||
let nextId = 1;
|
||||
|
||||
/**
|
||||
* Builds a user and an OAuth access token with read/write/create scopes for
|
||||
* use with the MCP test helpers.
|
||||
*
|
||||
* @param overrides - optional team id and role overrides.
|
||||
* @returns the created user and their access token.
|
||||
*/
|
||||
export async function buildOAuthUser(
|
||||
overrides: { teamId?: string; role?: string } = {}
|
||||
) {
|
||||
const user =
|
||||
overrides.role === "admin"
|
||||
? await buildAdmin(overrides.teamId ? { teamId: overrides.teamId } : {})
|
||||
: await buildUser(overrides.teamId ? { teamId: overrides.teamId } : {});
|
||||
const auth = await buildOAuthAuthentication({
|
||||
user,
|
||||
scope: [Scope.Read, Scope.Write, Scope.Create],
|
||||
});
|
||||
return { user, accessToken: auth.accessToken! };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns HTTP headers required for MCP requests with OAuth authentication.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Scope } from "@shared/types";
|
||||
import { Attachment } from "@server/models";
|
||||
import { buildOAuthAuthentication, buildUser } from "@server/test/factories";
|
||||
import { getTestServer } from "@server/test/support";
|
||||
import { buildOAuthUser, callMcpTool } from "@server/test/McpHelper";
|
||||
|
||||
const server = getTestServer();
|
||||
|
||||
describe("create_attachment", () => {
|
||||
it("returns absolute uploadUrl and proxied attachment url", async () => {
|
||||
const { accessToken } = await buildOAuthUser();
|
||||
const res = await callMcpTool(server, accessToken, "create_attachment", {
|
||||
contentType: "image/png",
|
||||
name: "test.png",
|
||||
size: 1000,
|
||||
});
|
||||
|
||||
expect(res?.result?.isError).toBeFalsy();
|
||||
const data = JSON.parse(res?.result?.content?.[0]?.text ?? "{}");
|
||||
|
||||
expect(data.uploadUrl).toMatch(/^https?:\/\//);
|
||||
expect(data.attachment.url).toMatch(/^https?:\/\//);
|
||||
expect(data.attachment.url).toContain("/api/attachments.redirect?id=");
|
||||
expect(data.curlCommand).toContain(data.uploadUrl);
|
||||
});
|
||||
|
||||
it("persists attachment record", async () => {
|
||||
const { user, accessToken } = await buildOAuthUser();
|
||||
const res = await callMcpTool(server, accessToken, "create_attachment", {
|
||||
contentType: "image/png",
|
||||
name: "test.png",
|
||||
size: 1000,
|
||||
});
|
||||
|
||||
const data = JSON.parse(res?.result?.content?.[0]?.text ?? "{}");
|
||||
const attachment = await Attachment.findByPk(data.attachment.id, {
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
expect(Number(attachment.size)).toEqual(1000);
|
||||
expect(attachment.contentType).toEqual("image/png");
|
||||
expect(attachment.userId).toEqual(user.id);
|
||||
expect(attachment.teamId).toEqual(user.teamId);
|
||||
});
|
||||
|
||||
it("rejects size larger than max", async () => {
|
||||
const { accessToken } = await buildOAuthUser();
|
||||
const res = await callMcpTool(server, accessToken, "create_attachment", {
|
||||
contentType: "image/png",
|
||||
name: "huge.png",
|
||||
size: 10_000_000_000,
|
||||
});
|
||||
expect(res?.result?.isError).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects negative size", async () => {
|
||||
const { accessToken } = await buildOAuthUser();
|
||||
const res = await callMcpTool(server, accessToken, "create_attachment", {
|
||||
contentType: "image/png",
|
||||
name: "neg.png",
|
||||
size: -1,
|
||||
});
|
||||
expect(res?.error ?? res?.result?.isError).toBeTruthy();
|
||||
});
|
||||
|
||||
it("rejects fractional size", async () => {
|
||||
const { accessToken } = await buildOAuthUser();
|
||||
const res = await callMcpTool(server, accessToken, "create_attachment", {
|
||||
contentType: "image/png",
|
||||
name: "frac.png",
|
||||
size: 1.5,
|
||||
});
|
||||
expect(res?.error ?? res?.result?.isError).toBeTruthy();
|
||||
});
|
||||
|
||||
it("read-only token does not have create_attachment tool", async () => {
|
||||
const user = await buildUser();
|
||||
const auth = await buildOAuthAuthentication({
|
||||
user,
|
||||
scope: [Scope.Read],
|
||||
});
|
||||
const res = await callMcpTool(
|
||||
server,
|
||||
auth.accessToken!,
|
||||
"create_attachment",
|
||||
{
|
||||
contentType: "image/png",
|
||||
name: "test.png",
|
||||
size: 1000,
|
||||
}
|
||||
);
|
||||
expect(res?.result?.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { buildCollection, buildUser } from "@server/test/factories";
|
||||
import { getTestServer } from "@server/test/support";
|
||||
import { buildOAuthUser, callMcpTool } from "@server/test/McpHelper";
|
||||
|
||||
const server = getTestServer();
|
||||
|
||||
describe("collection tools", () => {
|
||||
it("list_collections returns user collections", async () => {
|
||||
const { user, accessToken } = await buildOAuthUser();
|
||||
const collection = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
const res = await callMcpTool(server, accessToken, "list_collections");
|
||||
const data = (res?.result?.content ?? []).map((c: { text: string }) =>
|
||||
JSON.parse(c.text)
|
||||
);
|
||||
|
||||
expect(data.length).toBeGreaterThanOrEqual(1);
|
||||
const ids = data.map((c: { id: string }) => c.id);
|
||||
expect(ids).toContain(collection.id);
|
||||
|
||||
const match = data.find((c: { id: string }) => c.id === collection.id) as {
|
||||
url: string;
|
||||
};
|
||||
expect(match.url).toMatch(/^https?:\/\//);
|
||||
});
|
||||
|
||||
it("list_collections does not return collections from another team", async () => {
|
||||
const { accessToken } = await buildOAuthUser();
|
||||
const otherUser = await buildUser();
|
||||
const otherCollection = await buildCollection({
|
||||
teamId: otherUser.teamId,
|
||||
userId: otherUser.id,
|
||||
});
|
||||
|
||||
const res = await callMcpTool(server, accessToken, "list_collections");
|
||||
const data = (res?.result?.content ?? []).map((c: { text: string }) =>
|
||||
JSON.parse(c.text)
|
||||
);
|
||||
|
||||
const ids = data.map((c: { id: string }) => c.id);
|
||||
expect(ids).not.toContain(otherCollection.id);
|
||||
});
|
||||
|
||||
it("create_collection creates with name, description, icon, color", async () => {
|
||||
const { accessToken } = await buildOAuthUser();
|
||||
|
||||
const res = await callMcpTool(server, accessToken, "create_collection", {
|
||||
name: "Test Collection",
|
||||
description: "A test description",
|
||||
icon: "rocket",
|
||||
color: "#FF0000",
|
||||
});
|
||||
const data = JSON.parse(res?.result?.content?.[0]?.text ?? "{}");
|
||||
|
||||
expect(data.name).toEqual("Test Collection");
|
||||
expect(data.icon).toEqual("rocket");
|
||||
expect(data.color).toEqual("#FF0000");
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.url).toMatch(/^https?:\/\//);
|
||||
});
|
||||
|
||||
it("update_collection updates fields on existing collection", async () => {
|
||||
const { user, accessToken } = await buildOAuthUser();
|
||||
const collection = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
const res = await callMcpTool(server, accessToken, "update_collection", {
|
||||
id: collection.id,
|
||||
name: "Updated Name",
|
||||
description: "Updated description",
|
||||
});
|
||||
const data = JSON.parse(res?.result?.content?.[0]?.text ?? "{}");
|
||||
|
||||
expect(data.name).toEqual("Updated Name");
|
||||
expect(data.url).toMatch(/^https?:\/\//);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,322 @@
|
||||
import { Scope } from "@shared/types";
|
||||
import type { ProsemirrorData } from "@shared/types";
|
||||
import {
|
||||
buildCollection,
|
||||
buildComment,
|
||||
buildCommentMark,
|
||||
buildDocument,
|
||||
buildOAuthAuthentication,
|
||||
buildUser,
|
||||
} from "@server/test/factories";
|
||||
import { getTestServer } from "@server/test/support";
|
||||
import { buildOAuthUser, callMcpTool } from "@server/test/McpHelper";
|
||||
|
||||
const server = getTestServer();
|
||||
|
||||
describe("list_comments", () => {
|
||||
it("returns comments on a document", 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 res = await callMcpTool(server, accessToken, "list_comments", {
|
||||
documentId: document.id,
|
||||
});
|
||||
const data = (res?.result?.content ?? []).map((c: { text: string }) =>
|
||||
JSON.parse(c.text)
|
||||
);
|
||||
|
||||
const ids = data.map((c: { id: string }) => c.id);
|
||||
expect(ids).toContain(comment.id);
|
||||
});
|
||||
|
||||
it("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("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();
|
||||
});
|
||||
});
|
||||
|
||||
describe("create_comment", () => {
|
||||
it("creates a comment on a document", 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: "This is a test comment",
|
||||
});
|
||||
const data = JSON.parse(res?.result?.content?.[0]?.text ?? "{}");
|
||||
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.documentId).toEqual(document.id);
|
||||
});
|
||||
|
||||
it("creates a reply to an existing 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,
|
||||
});
|
||||
const parentComment = await buildComment({
|
||||
userId: user.id,
|
||||
documentId: document.id,
|
||||
});
|
||||
|
||||
const res = await callMcpTool(server, accessToken, "create_comment", {
|
||||
documentId: document.id,
|
||||
text: "This is a reply",
|
||||
parentCommentId: parentComment.id,
|
||||
});
|
||||
const data = JSON.parse(res?.result?.content?.[0]?.text ?? "{}");
|
||||
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.parentCommentId).toEqual(parentComment.id);
|
||||
});
|
||||
|
||||
it("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();
|
||||
});
|
||||
});
|
||||
|
||||
describe("update_comment", () => {
|
||||
it("updates text", 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 res = await callMcpTool(server, accessToken, "update_comment", {
|
||||
id: comment.id,
|
||||
text: "Updated comment text",
|
||||
});
|
||||
const data = JSON.parse(res?.result?.content?.[0]?.text ?? "{}");
|
||||
|
||||
expect(data.id).toEqual(comment.id);
|
||||
expect(data.text).toContain("Updated comment text");
|
||||
});
|
||||
|
||||
it("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);
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete_comment", () => {
|
||||
it("deletes own 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,
|
||||
});
|
||||
const comment = await buildComment({
|
||||
userId: user.id,
|
||||
documentId: document.id,
|
||||
});
|
||||
|
||||
const res = await callMcpTool(server, accessToken, "delete_comment", {
|
||||
id: comment.id,
|
||||
});
|
||||
const data = JSON.parse(res?.result?.content?.[0]?.text ?? "{}");
|
||||
|
||||
expect(data.success).toBe(true);
|
||||
});
|
||||
|
||||
it("fails for non-author non-admin", async () => {
|
||||
const { user } = 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 otherUser = await buildUser({ teamId: user.teamId });
|
||||
const otherAuth = await buildOAuthAuthentication({
|
||||
user: otherUser,
|
||||
scope: [Scope.Read, Scope.Write, Scope.Create],
|
||||
});
|
||||
|
||||
const res = await callMcpTool(
|
||||
server,
|
||||
otherAuth.accessToken!,
|
||||
"delete_comment",
|
||||
{ id: comment.id }
|
||||
);
|
||||
|
||||
expect(res?.result?.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,365 @@
|
||||
import { CollectionPermission, Scope } from "@shared/types";
|
||||
import {
|
||||
buildUser,
|
||||
buildCollection,
|
||||
buildDocument,
|
||||
buildOAuthAuthentication,
|
||||
} from "@server/test/factories";
|
||||
import { getTestServer } from "@server/test/support";
|
||||
import { buildOAuthUser, callMcpTool } from "@server/test/McpHelper";
|
||||
|
||||
const server = getTestServer();
|
||||
|
||||
describe("list_documents", () => {
|
||||
it("returns recent documents", 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, "list_documents");
|
||||
const data = (res?.result?.content ?? []).map((c: { text: string }) =>
|
||||
JSON.parse(c.text)
|
||||
);
|
||||
|
||||
const ids = data.map((d: { document: { id: string } }) => d.document.id);
|
||||
expect(ids).toContain(document.id);
|
||||
|
||||
const match = data.find(
|
||||
(d: { document: { id: string } }) => d.document.id === document.id
|
||||
) as { document: { url: string } };
|
||||
expect(match.document.url).toMatch(/^https?:\/\//);
|
||||
});
|
||||
|
||||
it("filters by collection", async () => {
|
||||
const { user, accessToken } = await buildOAuthUser();
|
||||
const collection1 = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
const collection2 = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
const doc1 = await buildDocument({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
collectionId: collection1.id,
|
||||
});
|
||||
await buildDocument({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
collectionId: collection2.id,
|
||||
});
|
||||
|
||||
const res = await callMcpTool(server, accessToken, "list_documents", {
|
||||
collectionId: collection1.id,
|
||||
});
|
||||
const data = (res?.result?.content ?? []).map((c: { text: string }) =>
|
||||
JSON.parse(c.text)
|
||||
);
|
||||
|
||||
const ids = data.map((d: { document: { id: string } }) => d.document.id);
|
||||
expect(ids).toContain(doc1.id);
|
||||
expect(
|
||||
data.every(
|
||||
(d: { document: { collectionId: string } }) =>
|
||||
d.document.collectionId === collection1.id
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not return private documents via exact urlId match", async () => {
|
||||
const owner = await buildUser();
|
||||
const otherUser = await buildUser({ teamId: owner.teamId });
|
||||
|
||||
const privateCollection = await buildCollection({
|
||||
teamId: owner.teamId,
|
||||
userId: owner.id,
|
||||
permission: null,
|
||||
});
|
||||
const privateDocument = await buildDocument({
|
||||
teamId: owner.teamId,
|
||||
userId: owner.id,
|
||||
collectionId: privateCollection.id,
|
||||
title: "Confidential",
|
||||
});
|
||||
|
||||
const auth = await buildOAuthAuthentication({
|
||||
user: otherUser,
|
||||
scope: [Scope.Read],
|
||||
});
|
||||
|
||||
const res = await callMcpTool(server, auth.accessToken!, "list_documents", {
|
||||
query: privateDocument.urlId,
|
||||
});
|
||||
|
||||
const data = (res?.result?.content ?? []).map((c: { text?: string }) =>
|
||||
JSON.parse(c.text ?? "{}")
|
||||
);
|
||||
const ids = data.map((d: { document: { id: string } }) => d.document.id);
|
||||
|
||||
expect(res?.result?.isError).not.toBe(true);
|
||||
expect(ids).not.toContain(privateDocument.id);
|
||||
});
|
||||
|
||||
it("returns documents via exact urlId match when the user has access", async () => {
|
||||
const user = await buildUser();
|
||||
const collection = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
permission: CollectionPermission.ReadWrite,
|
||||
});
|
||||
const document = await buildDocument({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
collectionId: collection.id,
|
||||
});
|
||||
|
||||
const auth = await buildOAuthAuthentication({
|
||||
user,
|
||||
scope: [Scope.Read],
|
||||
});
|
||||
|
||||
const res = await callMcpTool(server, auth.accessToken!, "list_documents", {
|
||||
query: document.urlId,
|
||||
});
|
||||
|
||||
const data = (res?.result?.content ?? []).map((c: { text?: string }) =>
|
||||
JSON.parse(c.text ?? "{}")
|
||||
);
|
||||
const ids = data.map((d: { document: { id: string } }) => d.document.id);
|
||||
|
||||
expect(ids).toContain(document.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("create_document", () => {
|
||||
it("creates in a collection", async () => {
|
||||
const { user, accessToken } = await buildOAuthUser();
|
||||
const collection = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
const res = await callMcpTool(server, accessToken, "create_document", {
|
||||
title: "New Document",
|
||||
text: "Hello world",
|
||||
collectionId: collection.id,
|
||||
});
|
||||
const data = JSON.parse(res?.result?.content?.[0]?.text ?? "{}");
|
||||
|
||||
expect(data.document.title).toEqual("New Document");
|
||||
expect(data.document.collectionId).toEqual(collection.id);
|
||||
expect(data.document.id).toBeDefined();
|
||||
expect(data.document.url).toMatch(/^https?:\/\//);
|
||||
});
|
||||
|
||||
it("creates nested under parent document", async () => {
|
||||
const { user, accessToken } = await buildOAuthUser();
|
||||
const collection = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
const parent = await buildDocument({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
collectionId: collection.id,
|
||||
});
|
||||
|
||||
const res = await callMcpTool(server, accessToken, "create_document", {
|
||||
title: "Child Document",
|
||||
text: "Nested content",
|
||||
parentDocumentId: parent.id,
|
||||
});
|
||||
const data = JSON.parse(res?.result?.content?.[0]?.text ?? "{}");
|
||||
|
||||
expect(data.document.title).toEqual("Child Document");
|
||||
expect(data.document.parentDocumentId).toEqual(parent.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("update_document", () => {
|
||||
it("updates title and text", 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, "update_document", {
|
||||
id: document.id,
|
||||
title: "Updated Title",
|
||||
text: "Updated content",
|
||||
});
|
||||
const data = JSON.parse(res?.result?.content?.[0]?.text ?? "{}");
|
||||
|
||||
expect(data.document.title).toEqual("Updated Title");
|
||||
expect(data.document.url).toMatch(/^https?:\/\//);
|
||||
});
|
||||
|
||||
it("unpublishes a document", 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, "update_document", {
|
||||
id: document.id,
|
||||
publish: false,
|
||||
});
|
||||
const data = JSON.parse(res?.result?.content?.[0]?.text ?? "{}");
|
||||
|
||||
expect(data.document.id).toEqual(document.id);
|
||||
expect(res?.result?.isError).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fails to unpublish a document with children", 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 buildDocument({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
collectionId: collection.id,
|
||||
parentDocumentId: document.id,
|
||||
});
|
||||
|
||||
const res = await callMcpTool(server, accessToken, "update_document", {
|
||||
id: document.id,
|
||||
publish: false,
|
||||
});
|
||||
|
||||
expect(res?.result?.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("move_document", () => {
|
||||
it("moves to a different collection", async () => {
|
||||
const { user, accessToken } = await buildOAuthUser();
|
||||
const collection1 = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
const collection2 = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
const document = await buildDocument({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
collectionId: collection1.id,
|
||||
});
|
||||
|
||||
const res = await callMcpTool(server, accessToken, "move_document", {
|
||||
id: document.id,
|
||||
collectionId: collection2.id,
|
||||
});
|
||||
const data = (res?.result?.content ?? []).map((c: { text: string }) =>
|
||||
JSON.parse(c.text)
|
||||
);
|
||||
|
||||
expect(res?.result?.isError).toBeUndefined();
|
||||
const moved = data.find(
|
||||
(d: { document: { id: string } }) => d.document.id === document.id
|
||||
) as { document: { collectionId: string } };
|
||||
expect(moved).toBeDefined();
|
||||
expect(moved.document.collectionId).toEqual(collection2.id);
|
||||
});
|
||||
|
||||
it("moves under a parent document", async () => {
|
||||
const { user, accessToken } = await buildOAuthUser();
|
||||
const collection = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
const parent = await buildDocument({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
collectionId: collection.id,
|
||||
});
|
||||
const child = await buildDocument({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
collectionId: collection.id,
|
||||
});
|
||||
|
||||
const res = await callMcpTool(server, accessToken, "move_document", {
|
||||
id: child.id,
|
||||
parentDocumentId: parent.id,
|
||||
});
|
||||
const data = (res?.result?.content ?? []).map((c: { text: string }) =>
|
||||
JSON.parse(c.text)
|
||||
);
|
||||
|
||||
expect(res?.result?.isError).toBeUndefined();
|
||||
const moved = data.find(
|
||||
(d: { document: { id: string } }) => d.document.id === child.id
|
||||
) as { document: { parentDocumentId: string } };
|
||||
expect(moved).toBeDefined();
|
||||
expect(moved.document.parentDocumentId).toEqual(parent.id);
|
||||
});
|
||||
|
||||
it("fails without collectionId or parentDocumentId", 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, "move_document", {
|
||||
id: document.id,
|
||||
});
|
||||
|
||||
expect(res?.result?.isError).toBe(true);
|
||||
});
|
||||
|
||||
it("fails when nesting a document inside itself", 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, "move_document", {
|
||||
id: document.id,
|
||||
parentDocumentId: document.id,
|
||||
});
|
||||
|
||||
expect(res?.result?.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import documentUpdater from "@server/commands/documentUpdater";
|
||||
import { Op } from "sequelize";
|
||||
import { Collection, Document } from "@server/models";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
import { authorize } from "@server/policies";
|
||||
import { authorize, can } from "@server/policies";
|
||||
import { presentDocument, presentNavigationNode } from "@server/presenters";
|
||||
import AuthenticationHelper from "@shared/helpers/AuthenticationHelper";
|
||||
import { UrlHelper } from "@shared/utils/UrlHelper";
|
||||
@@ -101,6 +101,9 @@ export function documentTools(server: McpServer, scopes: string[]) {
|
||||
exactMatch = await Document.findByPk(query, {
|
||||
userId: user.id,
|
||||
});
|
||||
if (exactMatch && !can(user, "read", exactMatch)) {
|
||||
exactMatch = null;
|
||||
}
|
||||
if (
|
||||
exactMatch &&
|
||||
collectionId &&
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { buildCollection, buildDocument } from "@server/test/factories";
|
||||
import { getTestServer } from "@server/test/support";
|
||||
import { buildOAuthUser, callMcpTool } from "@server/test/McpHelper";
|
||||
|
||||
const server = getTestServer();
|
||||
|
||||
describe("fetch", () => {
|
||||
it("returns collection details", async () => {
|
||||
const { user, accessToken } = await buildOAuthUser();
|
||||
const collection = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
const res = await callMcpTool(server, accessToken, "fetch", {
|
||||
resource: "collection",
|
||||
id: collection.id,
|
||||
});
|
||||
|
||||
expect(res?.result?.content).toBeDefined();
|
||||
expect(res!.result!.content!.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const data = JSON.parse(res!.result!.content![0].text ?? "{}");
|
||||
expect(data.id).toEqual(collection.id);
|
||||
expect(data.url).toMatch(/^https?:\/\//);
|
||||
});
|
||||
|
||||
it("returns document metadata and markdown", 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,
|
||||
text: "# Hello\n\nWorld",
|
||||
});
|
||||
|
||||
const res = await callMcpTool(server, accessToken, "fetch", {
|
||||
resource: "document",
|
||||
id: document.id,
|
||||
});
|
||||
|
||||
expect(res?.result?.content).toBeDefined();
|
||||
expect(res!.result!.content!.length).toEqual(2);
|
||||
|
||||
// First content is JSON metadata
|
||||
const metadata = JSON.parse(res!.result!.content![0].text ?? "{}");
|
||||
expect(metadata.document.id).toEqual(document.id);
|
||||
expect(metadata.document.title).toEqual(document.title);
|
||||
expect(metadata.document.url).toMatch(/^https?:\/\//);
|
||||
|
||||
// Second content is markdown text
|
||||
expect(res!.result!.content![1].text).toContain("Hello");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user