mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
feat: Improve MCP ability to read tree hierarchy (#12102)
* feat: Improve MCP ability to read tree heirarchy * PR feedback
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -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,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)
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user