feat: Expose moving documents within a collection (#11799)

This commit is contained in:
Tom Moor
2026-03-17 22:40:25 -04:00
committed by GitHub
parent 62cfd4e9bc
commit 3740e09e5c
2 changed files with 86 additions and 12 deletions
+62 -12
View File
@@ -23,6 +23,7 @@ import {
error,
success,
buildAPIContext,
buildSiblingIndexMap,
getActorFromContext,
pathToUrl,
withTracing,
@@ -138,11 +139,18 @@ export function documentTools(server: McpServer, scopes: string[]) {
const effectiveOffset = offset ?? 0;
const effectiveLimit = limit ?? 25;
let indexMap: Map<string, number> | undefined;
if (collectionId) {
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
includeDocumentStructure: true,
});
authorize(user, "readDocument", collection);
if (collection?.documentStructure) {
indexMap = buildSiblingIndexMap(collection.documentStructure);
}
}
if (query) {
@@ -180,7 +188,14 @@ export function documentTools(server: McpServer, scopes: string[]) {
includeText: false,
})
);
return { ...doc, context: result.context };
const siblingIndex = indexMap?.get(result.document.id);
return {
...doc,
context: result.context,
...(siblingIndex !== undefined && {
index: siblingIndex,
}),
};
})
);
@@ -192,7 +207,12 @@ export function documentTools(server: McpServer, scopes: string[]) {
includeText: false,
})
);
presented.unshift({ ...doc, context: undefined });
const siblingIndex = indexMap?.get(exactMatch.id);
presented.unshift({
...doc,
context: undefined,
...(siblingIndex !== undefined && { index: siblingIndex }),
});
}
return success(presented);
@@ -215,15 +235,20 @@ export function documentTools(server: McpServer, scopes: string[]) {
});
const presented = await Promise.all(
documents.map(async (document) =>
pathToUrl(
documents.map(async (document) => {
const result = pathToUrl(
user.team,
await presentDocument(undefined, document, {
includeData: false,
includeText: false,
})
)
)
);
const siblingIndex = indexMap?.get(document.id);
if (siblingIndex !== undefined) {
result.index = siblingIndex;
}
return result;
})
);
return success(presented);
} catch (message) {
@@ -349,7 +374,7 @@ export function documentTools(server: McpServer, scopes: string[]) {
{
title: "Move document",
description:
"Moves a document to a different collection or parent document. Provide either a collectionId to move to the root of a collection, or a parentDocumentId to nest under another document.",
"Moves a document to a different location or reorders it within its current parent. Provide a collectionId to move to the root of a collection, a parentDocumentId to nest under another document, and/or an index to control position among siblings.",
annotations: {
idempotentHint: false,
readOnlyHint: false,
@@ -370,6 +395,14 @@ export function documentTools(server: McpServer, scopes: string[]) {
.describe(
"The ID of the document to nest this document under. The document will be moved to the parent's collection."
),
index: z
.number()
.int()
.min(0)
.optional()
.describe(
"The zero-based position to insert the document among its siblings. Use this to reorder documents within the same collection and parent. Omit to place at the end."
),
},
},
withTracing("move_document", async (input, context) => {
@@ -421,22 +454,39 @@ export function documentTools(server: McpServer, scopes: string[]) {
authorize(user, "updateDocument", collection);
}
const { documents } = await documentMover(ctx, {
const { documents, collections } = await documentMover(ctx, {
document,
collectionId: collectionId ?? null,
parentDocumentId: input.parentDocumentId ?? null,
index: input.index,
});
const indexMap = new Map<string, number>();
for (const col of collections) {
if (col.documentStructure) {
for (const [id, idx] of buildSiblingIndexMap(
col.documentStructure
)) {
indexMap.set(id, idx);
}
}
}
const presented = await Promise.all(
documents.map(async (doc) =>
pathToUrl(
documents.map(async (doc) => {
const result = pathToUrl(
user.team,
await presentDocument(undefined, doc, {
includeData: false,
includeText: false,
})
)
)
);
const siblingIndex = indexMap.get(doc.id);
if (siblingIndex !== undefined) {
result.index = siblingIndex;
}
return result;
})
);
return success(presented);
});
+24
View File
@@ -4,6 +4,7 @@ import type { Team, User } from "@server/models";
import { addTags } from "@server/logging/tracer";
import { traceFunction } from "@server/logging/tracing";
import { type APIContext, AuthenticationType } from "@server/types";
import type { NavigationNode } from "@shared/types";
interface McpContext {
authInfo?: AuthInfo;
@@ -137,6 +138,29 @@ export function withResourceTracing<F extends (...args: any[]) => any>(
} as F);
}
/**
* Builds a map from document ID to its zero-based index among siblings,
* derived from a collection's document structure.
*
* @param nodes - the top-level navigation nodes from a collection's documentStructure.
* @returns a map of document ID to sibling index.
*/
export function buildSiblingIndexMap(
nodes: NavigationNode[]
): Map<string, number> {
const map = new Map<string, number>();
function walk(children: NavigationNode[]) {
children.forEach((node, idx) => {
map.set(node.id, idx);
walk(node.children);
});
}
walk(nodes);
return map;
}
/**
* Utility function to construct a URL by joining a team URL with a path segment.
*