Refactor MCP tests (#12347)

This commit is contained in:
Tom Moor
2026-05-13 20:57:55 -04:00
committed by GitHub
parent 0d76dfc9f4
commit 4a324784be
8 changed files with 949 additions and 821 deletions
+2 -820
View File
@@ -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();
+23
View File
@@ -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.
*
+93
View File
@@ -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);
});
});
+82
View File
@@ -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?:\/\//);
});
});
+322
View File
@@ -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);
});
});
+365
View File
@@ -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);
});
});
+4 -1
View File
@@ -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 &&
+58
View File
@@ -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");
});
});