mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<Document> {
|
||||
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);
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user