From 492af6683b3b016a134e13b2ba23821e52893a85 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 6 Jun 2026 15:08:52 -0400 Subject: [PATCH] Add document restore functionality to MCP tools (#12575) * Add restore_document MCP tool and archived/trashed listing Closes the delete/restore asymmetry in the MCP server: previously documents could be archived or trashed via delete_document but never recovered. - Add restore_document tool to recover archived or trashed documents, optionally into a different collection. - Add a status option ("archived" | "trashed") to list_documents so agents can discover what to restore. - Extract the documents.restore route logic into a shared documentRestorer command, used by both the REST endpoint and the MCP tool. https://claude.ai/code/session_01HpFcYtgEZJ96iaFMuGGCmc * Use type-only import for Document in documentRestorer https://claude.ai/code/session_01HpFcYtgEZJ96iaFMuGGCmc * Revert archived/trashed status option on list_documents Keeps the restore_document tool and shared documentRestorer command; removes the list_documents status filter and its tests. https://claude.ai/code/session_01HpFcYtgEZJ96iaFMuGGCmc --------- Co-authored-by: Claude --- server/commands/documentRestorer.ts | 98 ++++++++++++++++++++++++ server/routes/api/documents/documents.ts | 61 +-------------- server/tools/documents.test.ts | 98 ++++++++++++++++++++++++ server/tools/documents.ts | 74 ++++++++++++++++++ 4 files changed, 272 insertions(+), 59 deletions(-) create mode 100644 server/commands/documentRestorer.ts diff --git a/server/commands/documentRestorer.ts b/server/commands/documentRestorer.ts new file mode 100644 index 0000000000..63e645ebac --- /dev/null +++ b/server/commands/documentRestorer.ts @@ -0,0 +1,98 @@ +import { traceFunction } from "@server/logging/tracing"; +import { ValidationError } from "@server/errors"; +import { Collection, Revision } from "@server/models"; +import type { Document } from "@server/models"; +import { authorize } from "@server/policies"; +import type { APIContext } from "@server/types"; +import { assertPresent } from "@server/validation"; + +type Props = { + /** The document to restore. Must be loaded with `paranoid: false`. */ + document: Document; + /** Destination collection to restore into. Defaults to the original collection. */ + collectionId?: string | null; + /** Revision to restore the document's content from, when not archived or deleted. */ + revisionId?: string | null; +}; + +/** + * Restores a previously archived or deleted document, or restores a document's + * content to a specific revision. Re-attaches the document to the destination + * collection's structure when applicable and authorizes the acting user. + * + * @param ctx - the API context, providing the acting user and transaction. + * @param props - the document and restore options. + * @returns the restored document. + * @throws ValidationError if the destination collection is not active. + */ +async function documentRestorer( + ctx: APIContext, + { document, collectionId, revisionId }: Props +): Promise { + const { user } = ctx.state.auth; + const { transaction } = ctx.state; + + const sourceCollectionId = document.collectionId; + const destCollectionId = collectionId ?? sourceCollectionId; + + const srcCollection = sourceCollectionId + ? await Collection.findByPk(sourceCollectionId, { + userId: user.id, + includeDocumentStructure: true, + paranoid: false, + transaction, + }) + : undefined; + + const destCollection = destCollectionId + ? await Collection.findByPk(destCollectionId, { + userId: user.id, + includeDocumentStructure: true, + transaction, + }) + : undefined; + + if (!destCollection?.isActive) { + throw ValidationError( + "Unable to restore, the collection may have been deleted or archived" + ); + } + + if (sourceCollectionId && sourceCollectionId !== destCollection.id) { + authorize(user, "updateDocument", srcCollection); + await srcCollection?.removeDocumentInStructure(document, { + save: true, + transaction, + }); + } + + if (document.deletedAt) { + authorize(user, "restore", document); + authorize(user, "updateDocument", destCollection); + + // restore a previously deleted document + await document.restoreTo(ctx, { collectionId: destCollection.id }); + } else if (document.archivedAt) { + authorize(user, "unarchive", document); + authorize(user, "updateDocument", destCollection); + + // restore a previously archived document + await document.restoreTo(ctx, { collectionId: destCollection.id }); + } else if (revisionId) { + // restore a document to a specific revision + authorize(user, "update", document); + const revision = await Revision.findByPk(revisionId, { transaction }); + authorize(document, "restore", revision); + + await document.restoreFromRevision(revision); + await document.saveWithCtx(ctx, undefined, { name: "restore" }); + } else { + assertPresent(revisionId, "revisionId is required"); + } + + return document; +} + +export default traceFunction({ + spanName: "documentRestorer", +})(documentRestorer); diff --git a/server/routes/api/documents/documents.ts b/server/routes/api/documents/documents.ts index b562173d38..91411068f8 100644 --- a/server/routes/api/documents/documents.ts +++ b/server/routes/api/documents/documents.ts @@ -29,6 +29,7 @@ import documentDuplicator from "@server/commands/documentDuplicator"; import documentLoader from "@server/commands/documentLoader"; import documentMover from "@server/commands/documentMover"; import documentPermanentDeleter from "@server/commands/documentPermanentDeleter"; +import documentRestorer from "@server/commands/documentRestorer"; import documentUpdater from "@server/commands/documentUpdater"; import env from "@server/env"; import { @@ -51,7 +52,6 @@ import { Document, DocumentInsight, Event, - Revision, SearchQuery, Template, User, @@ -89,7 +89,6 @@ import { RateLimiterStrategy } from "@server/utils/RateLimiter"; import { convertBareUrlsToEmbedMarkdown } from "@server/utils/embeds"; import { streamZipResponse } from "@server/utils/koa"; import { getTeamFromContext } from "@server/utils/passport"; -import { assertPresent } from "@server/validation"; import pagination, { paginateQuery } from "../middlewares/pagination"; import * as T from "./schema"; import { @@ -968,63 +967,7 @@ router.post( transaction, }); - const sourceCollectionId = document.collectionId; - const destCollectionId = collectionId ?? sourceCollectionId; - - const srcCollection = sourceCollectionId - ? await Collection.findByPk(sourceCollectionId, { - userId: user.id, - includeDocumentStructure: true, - paranoid: false, - transaction, - }) - : undefined; - - const destCollection = destCollectionId - ? await Collection.findByPk(destCollectionId, { - userId: user.id, - includeDocumentStructure: true, - transaction, - }) - : undefined; - - if (!destCollection?.isActive) { - throw ValidationError( - "Unable to restore, the collection may have been deleted or archived" - ); - } - - if (sourceCollectionId && sourceCollectionId !== destCollectionId) { - authorize(user, "updateDocument", srcCollection); - await srcCollection?.removeDocumentInStructure(document, { - save: true, - transaction, - }); - } - - if (document.deletedAt) { - authorize(user, "restore", document); - authorize(user, "updateDocument", destCollection); - - // restore a previously deleted document - await document.restoreTo(ctx, { collectionId: destCollectionId! }); // destCollectionId is guaranteed to be defined here - } else if (document.archivedAt) { - authorize(user, "unarchive", document); - authorize(user, "updateDocument", destCollection); - - // restore a previously archived document - await document.restoreTo(ctx, { collectionId: destCollectionId! }); // destCollectionId is guaranteed to be defined here - } else if (revisionId) { - // restore a document to a specific revision - authorize(user, "update", document); - const revision = await Revision.findByPk(revisionId, { transaction }); - authorize(document, "restore", revision); - - await document.restoreFromRevision(revision); - await document.saveWithCtx(ctx, undefined, { name: "restore" }); - } else { - assertPresent(revisionId, "revisionId is required"); - } + await documentRestorer(ctx, { document, collectionId, revisionId }); ctx.body = { data: await presentDocument(ctx, document), diff --git a/server/tools/documents.test.ts b/server/tools/documents.test.ts index 5df4294cc4..160731c16f 100644 --- a/server/tools/documents.test.ts +++ b/server/tools/documents.test.ts @@ -458,3 +458,101 @@ describe("move_document", () => { expect(res?.result?.isError).toBe(true); }); }); + +describe("restore_document", () => { + it("restores an archived 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, + archivedAt: new Date(), + }); + + const res = await callMcpTool(server, accessToken, "restore_document", { + id: document.id, + }); + const data = JSON.parse(res?.result?.content?.[0]?.text ?? "{}"); + + expect(res?.result?.isError).toBeUndefined(); + expect(data.document.id).toEqual(document.id); + + const reloaded = await Document.unscoped().findByPk(document.id); + expect(reloaded?.archivedAt).toBeNull(); + }); + + it("restores a trashed 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, + }); + await document.destroy(); + + const res = await callMcpTool(server, accessToken, "restore_document", { + id: document.id, + }); + + expect(res?.result?.isError).toBeUndefined(); + + const reloaded = await Document.unscoped().findByPk(document.id, { + paranoid: false, + }); + expect(reloaded?.deletedAt).toBeNull(); + }); + + it("restores into a different collection", async () => { + const { user, accessToken } = await buildOAuthUser(); + const source = await buildCollection({ + teamId: user.teamId, + userId: user.id, + }); + const destination = await buildCollection({ + teamId: user.teamId, + userId: user.id, + }); + const document = await buildDocument({ + teamId: user.teamId, + userId: user.id, + collectionId: source.id, + archivedAt: new Date(), + }); + + const res = await callMcpTool(server, accessToken, "restore_document", { + id: document.id, + collectionId: destination.id, + }); + const data = JSON.parse(res?.result?.content?.[0]?.text ?? "{}"); + + expect(res?.result?.isError).toBeUndefined(); + expect(data.document.collectionId).toEqual(destination.id); + }); + + it("fails when the document is not archived or trashed", 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, "restore_document", { + id: document.id, + }); + + expect(res?.result?.isError).toBe(true); + }); +}); diff --git a/server/tools/documents.ts b/server/tools/documents.ts index 3e09d64e1d..e80be278c1 100644 --- a/server/tools/documents.ts +++ b/server/tools/documents.ts @@ -6,6 +6,7 @@ import documentCreator, { authorizeDocumentPublish, } from "@server/commands/documentCreator"; import documentMover from "@server/commands/documentMover"; +import documentRestorer from "@server/commands/documentRestorer"; import documentUpdater from "@server/commands/documentUpdater"; import { Op } from "sequelize"; import { Collection, Document } from "@server/models"; @@ -697,4 +698,77 @@ export function documentTools(server: McpServer, scopes: string[]) { }) ); } + + if (AuthenticationHelper.canAccess("documents.restore", scopes)) { + server.registerTool( + "restore_document", + { + title: "Restore document", + description: + "Restores an archived or trashed document, making it active again. Optionally provide a collectionId to restore the document into a different collection; otherwise it returns to its original collection.", + annotations: { + idempotentHint: false, + readOnlyHint: false, + }, + inputSchema: { + id: z + .string() + .describe("The unique identifier of the document to restore."), + collectionId: optionalString().describe( + "The collection to restore the document into. Defaults to its original collection." + ), + }, + }, + withTracing("restore_document", async ({ id, collectionId }, context) => { + try { + const ctx = buildAPIContext(context); + const { user } = ctx.state.auth; + + return await sequelize.transaction(async (transaction) => { + ctx.state.transaction = transaction; + ctx.context.transaction = transaction; + + const document = await Document.findByPk(id, { + userId: user.id, + paranoid: false, + rejectOnEmpty: true, + transaction, + }); + + if (!document.deletedAt && !document.archivedAt) { + return error("Document is not archived or trashed"); + } + + await documentRestorer(ctx, { document, collectionId }); + + const [{ text, ...attributes }, breadcrumb] = await Promise.all([ + presentDocument(document, { + includeData: false, + includeText: true, + includeUpdatedAt: true, + }), + getDocumentBreadcrumb(document, user), + ]); + return { + content: [ + { + type: "text" as const, + text: JSON.stringify({ + document: pathToUrl(user.team, attributes), + ...(breadcrumb !== undefined && { breadcrumb }), + }), + }, + { + type: "text" as const, + text: typeof text === "string" ? text : "", + }, + ], + } satisfies CallToolResult; + }); + } catch (message) { + return error(message); + } + }) + ); + } }