feat: Improve MCP ability to read tree hierarchy (#12102)

* feat: Improve MCP ability to read tree heirarchy

* PR feedback
This commit is contained in:
Tom Moor
2026-04-18 08:09:55 -04:00
committed by GitHub
parent 4dd24b59ad
commit 5cb4b71652
4 changed files with 101 additions and 4 deletions
+2
View File
@@ -14,6 +14,7 @@ import presentGroupUser from "./groupUser";
import presentImport from "./import";
import presentIntegration from "./integration";
import presentMembership from "./membership";
import presentNavigationNode from "./navigationNode";
import presentOAuthClient, { presentPublishedOAuthClient } from "./oauthClient";
import presentPin from "./pin";
import presentPolicies from "./policy";
@@ -50,6 +51,7 @@ export {
presentImport,
presentIntegration,
presentMembership,
presentNavigationNode,
presentOAuthClient,
presentPublishedOAuthClient,
presentPublicTeam,
+33
View File
@@ -0,0 +1,33 @@
import type { Team } from "@server/models";
import type { NavigationNode } from "@shared/types";
export interface PresentedNavigationNode {
id: string;
title: string;
url: string;
children: PresentedNavigationNode[];
}
/**
* Projects a NavigationNode and its descendants to the minimal shape exposed
* to API clients, resolving relative `url` fields against the team's base URL.
*
* @param team - the team whose base URL anchors relative paths.
* @param node - the navigation node to present.
* @returns the presented node with an absolute URL and recursively presented children.
*/
export default function presentNavigationNode(
team: Team,
node: NavigationNode
): PresentedNavigationNode {
return {
id: node.id,
title: node.title,
url: /^https?:\/\//.test(node.url)
? node.url
: new URL(node.url, team.url).href,
children: (node.children ?? []).map((child) =>
presentNavigationNode(team, child)
),
};
}
+60 -2
View File
@@ -8,7 +8,7 @@ import { Op } from "sequelize";
import { Collection, Document } from "@server/models";
import { sequelize } from "@server/storage/database";
import { authorize } from "@server/policies";
import { presentDocument } from "@server/presenters";
import { presentDocument, presentNavigationNode } from "@server/presenters";
import AuthenticationHelper from "@shared/helpers/AuthenticationHelper";
import { UrlHelper } from "@shared/utils/UrlHelper";
import {
@@ -21,6 +21,8 @@ import {
withTracing,
} from "./util";
import { TextEditMode } from "@shared/types";
import { CacheHelper } from "@server/utils/CacheHelper";
import { RedisPrefixHelper } from "@server/utils/RedisPrefixHelper";
import SearchProviderManager from "@server/utils/SearchProviderManager";
/**
@@ -37,7 +39,7 @@ export function documentTools(server: McpServer, scopes: string[]) {
{
title: "Search documents",
description:
"Searches documents the user has access to. Performs full-text search across document content when a query is provided, or lists recent documents when no query is given. Optionally filter by collection.",
"Searches documents the user has access to. Performs full-text search across document content when a query is provided, or lists recent documents when no query is given. Optionally filter by collection. To retrieve the full contents or hierarchy of a specific collection, use list_collection_documents instead.",
annotations: {
idempotentHint: true,
readOnlyHint: true,
@@ -200,6 +202,62 @@ export function documentTools(server: McpServer, scopes: string[]) {
);
}
if (AuthenticationHelper.canAccess("collections.documents", scopes)) {
server.registerTool(
"list_collection_documents",
{
title: "List all documents in a collection",
description:
"Returns the complete hierarchical tree of published documents in a collection, including nested sub-documents. Use this to enumerate every document in a collection or to understand parent/child relationships. Drafts and archived documents are not included.",
annotations: {
idempotentHint: true,
readOnlyHint: true,
},
inputSchema: {
collectionId: z
.string()
.describe(
"The ID of the collection whose document tree to return."
),
},
},
withTracing(
"list_collection_documents",
async ({ collectionId }, extra) => {
try {
const user = getActorFromContext(extra);
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
rejectOnEmpty: true,
});
authorize(user, "readDocument", collection);
const documentStructure = await CacheHelper.getDataOrSet(
RedisPrefixHelper.getCollectionDocumentsKey(collection.id),
async () =>
(
await Collection.findByPk(collection.id, {
attributes: ["documentStructure"],
includeDocumentStructure: true,
rejectOnEmpty: true,
})
).documentStructure,
60
);
const tree = (documentStructure ?? []).map((node) =>
presentNavigationNode(user.team, node)
);
return success(tree);
} catch (message) {
return error(message);
}
}
)
);
}
if (AuthenticationHelper.canAccess("documents.create", scopes)) {
server.registerTool(
"create_document",
+6 -2
View File
@@ -6,6 +6,7 @@ import { authorize, can } from "@server/policies";
import {
presentCollection,
presentDocument,
presentNavigationNode,
presentUser,
} from "@server/presenters";
import AuthenticationHelper from "@shared/helpers/AuthenticationHelper";
@@ -73,7 +74,7 @@ export function fetchTool(server: McpServer, scopes: string[]) {
{
title: "Fetch",
description:
'Fetches a document, collection, or user by type and ID. For users, "current_user" can be used as the ID to get the authenticated user.',
'Fetches a document, collection, or user by type and ID. When fetching a collection the response includes the full hierarchical document tree. For users, "current_user" can be used as the ID to get the authenticated user.',
annotations: {
idempotentHint: true,
readOnlyHint: true,
@@ -126,6 +127,7 @@ export function fetchTool(server: McpServer, scopes: string[]) {
case "collection": {
const collection = await Collection.findByPk(id, {
userId: actor.id,
includeDocumentStructure: true,
rejectOnEmpty: true,
});
@@ -135,7 +137,9 @@ export function fetchTool(server: McpServer, scopes: string[]) {
const presented = await presentCollection(undefined, collection);
return success([
pathToUrl(actor.team, presented),
collection.documentStructure ?? [],
(collection.documentStructure ?? []).map((node) =>
presentNavigationNode(actor.team, node)
),
]);
}