From 4a324784bed237261eed435ccb9e7b0db41d2b41 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Wed, 13 May 2026 20:57:55 -0400 Subject: [PATCH] Refactor MCP tests (#12347) --- server/routes/mcp/index.test.ts | 822 +------------------------------ server/test/McpHelper.ts | 23 + server/tools/attachments.test.ts | 93 ++++ server/tools/collections.test.ts | 82 +++ server/tools/comments.test.ts | 322 ++++++++++++ server/tools/documents.test.ts | 365 ++++++++++++++ server/tools/documents.ts | 5 +- server/tools/fetch.test.ts | 58 +++ 8 files changed, 949 insertions(+), 821 deletions(-) create mode 100644 server/tools/attachments.test.ts create mode 100644 server/tools/collections.test.ts create mode 100644 server/tools/comments.test.ts create mode 100644 server/tools/documents.test.ts create mode 100644 server/tools/fetch.test.ts diff --git a/server/routes/mcp/index.test.ts b/server/routes/mcp/index.test.ts index 09422e2b04..d62f9b0a63 100644 --- a/server/routes/mcp/index.test.ts +++ b/server/routes/mcp/index.test.ts @@ -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(); diff --git a/server/test/McpHelper.ts b/server/test/McpHelper.ts index ecef88d9e6..a45934b711 100644 --- a/server/test/McpHelper.ts +++ b/server/test/McpHelper.ts @@ -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. * diff --git a/server/tools/attachments.test.ts b/server/tools/attachments.test.ts new file mode 100644 index 0000000000..158f187347 --- /dev/null +++ b/server/tools/attachments.test.ts @@ -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); + }); +}); diff --git a/server/tools/collections.test.ts b/server/tools/collections.test.ts new file mode 100644 index 0000000000..053093862e --- /dev/null +++ b/server/tools/collections.test.ts @@ -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?:\/\//); + }); +}); diff --git a/server/tools/comments.test.ts b/server/tools/comments.test.ts new file mode 100644 index 0000000000..4a3a0d7f5d --- /dev/null +++ b/server/tools/comments.test.ts @@ -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); + }); +}); diff --git a/server/tools/documents.test.ts b/server/tools/documents.test.ts new file mode 100644 index 0000000000..826ba1a428 --- /dev/null +++ b/server/tools/documents.test.ts @@ -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); + }); +}); diff --git a/server/tools/documents.ts b/server/tools/documents.ts index 00f807f407..30bde79273 100644 --- a/server/tools/documents.ts +++ b/server/tools/documents.ts @@ -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 && diff --git a/server/tools/fetch.test.ts b/server/tools/fetch.test.ts new file mode 100644 index 0000000000..3209a4d7b0 --- /dev/null +++ b/server/tools/fetch.test.ts @@ -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"); + }); +});