mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
feat: Return breadcrumb in MCP responses (#12203)
* feat: Add breadcrumb to MCP responses
* test: Update MCP test expectations for new response envelope
Tests were reading the old flat document shape; update them to read
through the new { document, breadcrumb } envelope.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* perf: Batch collection lookups when building breadcrumbs
Add getBreadcrumbsForDocuments helper that loads all referenced
collections in one query (with the user's memberships) and resolves
breadcrumbs from the per-collection cached documentStructure. Use it
in list_documents and move_document, replacing the per-document
Collection.findByPk that produced an N+1 query pattern.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test: Add coverage for getBreadcrumbsForDocuments and parallelize doc + breadcrumb loads
Run presentDocument and getDocumentBreadcrumb concurrently in fetch,
create_document, and update_document so the breadcrumb lookup no
longer adds latency on top of the presenter.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -370,7 +370,7 @@ class Collection extends ParanoidModel<
|
||||
CacheHelper.setData(
|
||||
RedisPrefixHelper.getCollectionDocumentsKey(model.id),
|
||||
model.documentStructure,
|
||||
60
|
||||
Collection.DOCUMENT_STRUCTURE_CACHE_TTL
|
||||
);
|
||||
|
||||
if (options.transaction) {
|
||||
@@ -560,6 +560,8 @@ class Collection extends ParanoidModel<
|
||||
direction: "asc",
|
||||
};
|
||||
|
||||
static DOCUMENT_STRUCTURE_CACHE_TTL = 60;
|
||||
|
||||
/**
|
||||
* Returns an array of unique userIds that are members of a collection,
|
||||
* either via group or direct membership.
|
||||
@@ -706,6 +708,29 @@ class Collection extends ParanoidModel<
|
||||
return !this.permission;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the collection's documentStructure via cache, populating it on
|
||||
* miss. The cache is kept fresh by this model's save hooks, so callers
|
||||
* should prefer this over re-fetching the column directly.
|
||||
*
|
||||
* @returns the cached documentStructure, or null when the collection has none.
|
||||
*/
|
||||
getCachedDocumentStructure = async (): Promise<NavigationNode[] | null> => {
|
||||
const result = await CacheHelper.getDataOrSet<NavigationNode[] | null>(
|
||||
RedisPrefixHelper.getCollectionDocumentsKey(this.id),
|
||||
async () =>
|
||||
(
|
||||
await (this.constructor as typeof Collection).findByPk(this.id, {
|
||||
attributes: ["documentStructure"],
|
||||
includeDocumentStructure: true,
|
||||
rejectOnEmpty: true,
|
||||
})
|
||||
).documentStructure,
|
||||
Collection.DOCUMENT_STRUCTURE_CACHE_TTL
|
||||
);
|
||||
return result ?? null;
|
||||
};
|
||||
|
||||
getDocumentTree = (documentId: string): NavigationNode | null => {
|
||||
if (!this.documentStructure) {
|
||||
return null;
|
||||
|
||||
@@ -38,8 +38,6 @@ import {
|
||||
presentFileOperation,
|
||||
} from "@server/presenters";
|
||||
import type { APIContext } from "@server/types";
|
||||
import { CacheHelper } from "@server/utils/CacheHelper";
|
||||
import { RedisPrefixHelper } from "@server/utils/RedisPrefixHelper";
|
||||
import { RateLimiterStrategy } from "@server/utils/RateLimiter";
|
||||
import { collectionIndexing } from "@server/utils/indexing";
|
||||
import pagination from "../middlewares/pagination";
|
||||
@@ -146,18 +144,7 @@ router.post(
|
||||
|
||||
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 documentStructure = await collection.getCachedDocumentStructure();
|
||||
|
||||
ctx.body = {
|
||||
data: documentStructure || [],
|
||||
|
||||
@@ -223,13 +223,13 @@ describe("POST /mcp/", () => {
|
||||
JSON.parse(c.text)
|
||||
);
|
||||
|
||||
const ids = data.map((d: { id: string }) => d.id);
|
||||
const ids = data.map((d: { document: { id: string } }) => d.document.id);
|
||||
expect(ids).toContain(document.id);
|
||||
|
||||
const match = data.find((d: { id: string }) => d.id === document.id) as {
|
||||
url: string;
|
||||
};
|
||||
expect(match.url).toMatch(/^https?:\/\//);
|
||||
const match = data.find(
|
||||
(d: { document: { id: string } }) => d.document.id === document.id
|
||||
) as { document: { url: string } };
|
||||
expect(match.document.url).toMatch(/^https?:\/\//);
|
||||
});
|
||||
|
||||
it("list_documents filters by collection", async () => {
|
||||
@@ -260,11 +260,12 @@ describe("POST /mcp/", () => {
|
||||
JSON.parse(c.text)
|
||||
);
|
||||
|
||||
const ids = data.map((d: { id: string }) => d.id);
|
||||
const ids = data.map((d: { document: { id: string } }) => d.document.id);
|
||||
expect(ids).toContain(doc1.id);
|
||||
expect(
|
||||
data.every(
|
||||
(d: { collectionId: string }) => d.collectionId === collection1.id
|
||||
(d: { document: { collectionId: string } }) =>
|
||||
d.document.collectionId === collection1.id
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
@@ -283,10 +284,10 @@ describe("POST /mcp/", () => {
|
||||
});
|
||||
const data = JSON.parse(res?.result?.content?.[0]?.text ?? "{}");
|
||||
|
||||
expect(data.title).toEqual("New Document");
|
||||
expect(data.collectionId).toEqual(collection.id);
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.url).toMatch(/^https?:\/\//);
|
||||
expect(data.document.title).toEqual("New Document");
|
||||
expect(data.document.collectionId).toEqual(collection.id);
|
||||
expect(data.document.id).toBeDefined();
|
||||
expect(data.document.url).toMatch(/^https?:\/\//);
|
||||
});
|
||||
|
||||
it("create_document creates nested under parent document", async () => {
|
||||
@@ -308,8 +309,8 @@ describe("POST /mcp/", () => {
|
||||
});
|
||||
const data = JSON.parse(res?.result?.content?.[0]?.text ?? "{}");
|
||||
|
||||
expect(data.title).toEqual("Child Document");
|
||||
expect(data.parentDocumentId).toEqual(parent.id);
|
||||
expect(data.document.title).toEqual("Child Document");
|
||||
expect(data.document.parentDocumentId).toEqual(parent.id);
|
||||
});
|
||||
|
||||
it("update_document updates title and text", async () => {
|
||||
@@ -331,8 +332,8 @@ describe("POST /mcp/", () => {
|
||||
});
|
||||
const data = JSON.parse(res?.result?.content?.[0]?.text ?? "{}");
|
||||
|
||||
expect(data.title).toEqual("Updated Title");
|
||||
expect(data.url).toMatch(/^https?:\/\//);
|
||||
expect(data.document.title).toEqual("Updated Title");
|
||||
expect(data.document.url).toMatch(/^https?:\/\//);
|
||||
});
|
||||
|
||||
it("update_document unpublishes a document", async () => {
|
||||
@@ -353,7 +354,7 @@ describe("POST /mcp/", () => {
|
||||
});
|
||||
const data = JSON.parse(res?.result?.content?.[0]?.text ?? "{}");
|
||||
|
||||
expect(data.id).toEqual(document.id);
|
||||
expect(data.document.id).toEqual(document.id);
|
||||
expect(res?.result?.isError).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -408,11 +409,11 @@ describe("POST /mcp/", () => {
|
||||
);
|
||||
|
||||
expect(res?.result?.isError).toBeUndefined();
|
||||
const moved = data.find((d: { id: string }) => d.id === document.id) as {
|
||||
collectionId: string;
|
||||
};
|
||||
const moved = data.find(
|
||||
(d: { document: { id: string } }) => d.document.id === document.id
|
||||
) as { document: { collectionId: string } };
|
||||
expect(moved).toBeDefined();
|
||||
expect(moved.collectionId).toEqual(collection2.id);
|
||||
expect(moved.document.collectionId).toEqual(collection2.id);
|
||||
});
|
||||
|
||||
it("move_document moves under a parent document", async () => {
|
||||
@@ -441,11 +442,11 @@ describe("POST /mcp/", () => {
|
||||
);
|
||||
|
||||
expect(res?.result?.isError).toBeUndefined();
|
||||
const moved = data.find((d: { id: string }) => d.id === child.id) as {
|
||||
parentDocumentId: string;
|
||||
};
|
||||
const moved = data.find(
|
||||
(d: { document: { id: string } }) => d.document.id === child.id
|
||||
) as { document: { parentDocumentId: string } };
|
||||
expect(moved).toBeDefined();
|
||||
expect(moved.parentDocumentId).toEqual(parent.id);
|
||||
expect(moved.document.parentDocumentId).toEqual(parent.id);
|
||||
});
|
||||
|
||||
it("move_document fails without collectionId or parentDocumentId", async () => {
|
||||
@@ -510,9 +511,9 @@ describe("POST /mcp/", () => {
|
||||
|
||||
// First content is JSON metadata
|
||||
const metadata = JSON.parse(res!.result!.content![0].text ?? "{}");
|
||||
expect(metadata.id).toEqual(document.id);
|
||||
expect(metadata.title).toEqual(document.title);
|
||||
expect(metadata.url).toMatch(/^https?:\/\//);
|
||||
expect(metadata.document.id).toEqual(document.id);
|
||||
expect(metadata.document.title).toEqual(document.title);
|
||||
expect(metadata.document.url).toMatch(/^https?:\/\//);
|
||||
|
||||
// Second content is markdown text
|
||||
expect(res!.result!.content![1].text).toContain("Hello");
|
||||
@@ -909,7 +910,7 @@ describe("POST /mcp/", () => {
|
||||
});
|
||||
expect(res?.result?.isError).toBeUndefined();
|
||||
const data = JSON.parse(res?.result?.content?.[0]?.text ?? "{}");
|
||||
expect(data.title).toEqual("Created Document");
|
||||
expect(data.document.title).toEqual("Created Document");
|
||||
});
|
||||
|
||||
it("create-scoped token does not have update_document tool", async () => {
|
||||
@@ -966,7 +967,7 @@ describe("POST /mcp/", () => {
|
||||
accessToken,
|
||||
"update_document",
|
||||
{
|
||||
id: created.id,
|
||||
id: created.document.id,
|
||||
title: "Updated by Write Token",
|
||||
}
|
||||
);
|
||||
|
||||
+82
-61
@@ -17,12 +17,12 @@ import {
|
||||
buildAPIContext,
|
||||
buildSiblingIndexMap,
|
||||
getActorFromContext,
|
||||
getBreadcrumbsForDocuments,
|
||||
getDocumentBreadcrumb,
|
||||
pathToUrl,
|
||||
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";
|
||||
|
||||
/**
|
||||
@@ -120,26 +120,37 @@ export function documentTools(server: McpServer, scopes: string[]) {
|
||||
limit: effectiveLimit,
|
||||
});
|
||||
|
||||
const filteredResults = results.filter(
|
||||
(result) => result.document.id !== exactMatch?.id
|
||||
);
|
||||
const breadcrumbs = await getBreadcrumbsForDocuments(
|
||||
[
|
||||
...(exactMatch ? [exactMatch] : []),
|
||||
...filteredResults.map((r) => r.document),
|
||||
],
|
||||
user
|
||||
);
|
||||
|
||||
const presented = await Promise.all(
|
||||
results
|
||||
.filter((result) => result.document.id !== exactMatch?.id)
|
||||
.map(async (result) => {
|
||||
const doc = pathToUrl(
|
||||
user.team,
|
||||
await presentDocument(undefined, result.document, {
|
||||
includeData: false,
|
||||
includeText: false,
|
||||
})
|
||||
);
|
||||
const siblingIndex = indexMap?.get(result.document.id);
|
||||
return {
|
||||
...doc,
|
||||
context: result.context,
|
||||
...(siblingIndex !== undefined && {
|
||||
index: siblingIndex,
|
||||
}),
|
||||
};
|
||||
})
|
||||
filteredResults.map(async (result) => {
|
||||
const doc = pathToUrl(
|
||||
user.team,
|
||||
await presentDocument(undefined, result.document, {
|
||||
includeData: false,
|
||||
includeText: false,
|
||||
})
|
||||
);
|
||||
const breadcrumb = breadcrumbs.get(result.document.id);
|
||||
const siblingIndex = indexMap?.get(result.document.id);
|
||||
return {
|
||||
document: doc,
|
||||
...(breadcrumb !== undefined && { breadcrumb }),
|
||||
context: result.context,
|
||||
...(siblingIndex !== undefined && {
|
||||
index: siblingIndex,
|
||||
}),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
if (exactMatch) {
|
||||
@@ -150,9 +161,11 @@ export function documentTools(server: McpServer, scopes: string[]) {
|
||||
includeText: false,
|
||||
})
|
||||
);
|
||||
const breadcrumb = breadcrumbs.get(exactMatch.id);
|
||||
const siblingIndex = indexMap?.get(exactMatch.id);
|
||||
presented.unshift({
|
||||
...doc,
|
||||
document: doc,
|
||||
...(breadcrumb !== undefined && { breadcrumb }),
|
||||
context: undefined,
|
||||
...(siblingIndex !== undefined && { index: siblingIndex }),
|
||||
});
|
||||
@@ -177,20 +190,27 @@ export function documentTools(server: McpServer, scopes: string[]) {
|
||||
limit: effectiveLimit,
|
||||
});
|
||||
|
||||
const breadcrumbs = await getBreadcrumbsForDocuments(
|
||||
documents,
|
||||
user
|
||||
);
|
||||
|
||||
const presented = await Promise.all(
|
||||
documents.map(async (document) => {
|
||||
const result = pathToUrl(
|
||||
const doc = pathToUrl(
|
||||
user.team,
|
||||
await presentDocument(undefined, document, {
|
||||
includeData: false,
|
||||
includeText: false,
|
||||
})
|
||||
);
|
||||
const breadcrumb = breadcrumbs.get(document.id);
|
||||
const siblingIndex = indexMap?.get(document.id);
|
||||
if (siblingIndex !== undefined) {
|
||||
result.index = siblingIndex;
|
||||
}
|
||||
return result;
|
||||
return {
|
||||
document: doc,
|
||||
...(breadcrumb !== undefined && { breadcrumb }),
|
||||
...(siblingIndex !== undefined && { index: siblingIndex }),
|
||||
};
|
||||
})
|
||||
);
|
||||
return success(presented);
|
||||
@@ -233,18 +253,8 @@ export function documentTools(server: McpServer, scopes: string[]) {
|
||||
});
|
||||
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 documentStructure =
|
||||
await collection.getCachedDocumentStructure();
|
||||
|
||||
const tree = (documentStructure ?? []).map((node) =>
|
||||
presentNavigationNode(user.team, node)
|
||||
@@ -339,20 +349,22 @@ export function documentTools(server: McpServer, scopes: string[]) {
|
||||
collectionId: collection?.id,
|
||||
});
|
||||
|
||||
const { text, ...attributes } = await presentDocument(
|
||||
undefined,
|
||||
document,
|
||||
{
|
||||
const [{ text, ...attributes }, breadcrumb] = await Promise.all([
|
||||
presentDocument(undefined, document, {
|
||||
includeData: false,
|
||||
includeText: true,
|
||||
includeUpdatedAt: true,
|
||||
}
|
||||
);
|
||||
}),
|
||||
getDocumentBreadcrumb(document, user),
|
||||
]);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify(pathToUrl(user.team, attributes)),
|
||||
text: JSON.stringify({
|
||||
document: pathToUrl(user.team, attributes),
|
||||
...(breadcrumb !== undefined && { breadcrumb }),
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: "text" as const,
|
||||
@@ -471,20 +483,27 @@ export function documentTools(server: McpServer, scopes: string[]) {
|
||||
}
|
||||
}
|
||||
|
||||
const breadcrumbs = await getBreadcrumbsForDocuments(
|
||||
documents,
|
||||
user
|
||||
);
|
||||
|
||||
const presented = await Promise.all(
|
||||
documents.map(async (doc) => {
|
||||
const result = pathToUrl(
|
||||
documents.map(async (document) => {
|
||||
const doc = pathToUrl(
|
||||
user.team,
|
||||
await presentDocument(undefined, doc, {
|
||||
await presentDocument(undefined, document, {
|
||||
includeData: false,
|
||||
includeText: false,
|
||||
})
|
||||
);
|
||||
const siblingIndex = indexMap.get(doc.id);
|
||||
if (siblingIndex !== undefined) {
|
||||
result.index = siblingIndex;
|
||||
}
|
||||
return result;
|
||||
const breadcrumb = breadcrumbs.get(document.id);
|
||||
const siblingIndex = indexMap.get(document.id);
|
||||
return {
|
||||
document: doc,
|
||||
...(breadcrumb !== undefined && { breadcrumb }),
|
||||
...(siblingIndex !== undefined && { index: siblingIndex }),
|
||||
};
|
||||
})
|
||||
);
|
||||
return success(presented);
|
||||
@@ -589,20 +608,22 @@ export function documentTools(server: McpServer, scopes: string[]) {
|
||||
});
|
||||
}
|
||||
|
||||
const { text, ...attributes } = await presentDocument(
|
||||
undefined,
|
||||
updated,
|
||||
{
|
||||
const [{ text, ...attributes }, breadcrumb] = await Promise.all([
|
||||
presentDocument(undefined, updated, {
|
||||
includeData: false,
|
||||
includeText: true,
|
||||
includeUpdatedAt: true,
|
||||
}
|
||||
);
|
||||
}),
|
||||
getDocumentBreadcrumb(updated, user),
|
||||
]);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify(pathToUrl(user.team, attributes)),
|
||||
text: JSON.stringify({
|
||||
document: pathToUrl(user.team, attributes),
|
||||
...(breadcrumb !== undefined && { breadcrumb }),
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: "text" as const,
|
||||
|
||||
+10
-7
@@ -14,6 +14,7 @@ import {
|
||||
error,
|
||||
success,
|
||||
getActorFromContext,
|
||||
getDocumentBreadcrumb,
|
||||
pathToUrl,
|
||||
withTracing,
|
||||
} from "./util";
|
||||
@@ -102,20 +103,22 @@ export function fetchTool(server: McpServer, scopes: string[]) {
|
||||
|
||||
authorize(actor, "read", document);
|
||||
|
||||
const { text, ...attributes } = await presentDocument(
|
||||
undefined,
|
||||
document,
|
||||
{
|
||||
const [{ text, ...attributes }, breadcrumb] = await Promise.all([
|
||||
presentDocument(undefined, document, {
|
||||
includeData: false,
|
||||
includeText: true,
|
||||
includeUpdatedAt: true,
|
||||
}
|
||||
);
|
||||
}),
|
||||
getDocumentBreadcrumb(document, actor),
|
||||
]);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify(pathToUrl(actor.team, attributes)),
|
||||
text: JSON.stringify({
|
||||
document: pathToUrl(actor.team, attributes),
|
||||
...(breadcrumb !== undefined && { breadcrumb }),
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: "text" as const,
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { CollectionPermission, type NavigationNode } from "@shared/types";
|
||||
import {
|
||||
buildCollection,
|
||||
buildDocument,
|
||||
buildTeam,
|
||||
buildUser,
|
||||
} from "@server/test/factories";
|
||||
import { buildBreadcrumb, getBreadcrumbsForDocuments } from "./util";
|
||||
|
||||
const node = (
|
||||
id: string,
|
||||
title: string,
|
||||
children: NavigationNode[] = []
|
||||
): NavigationNode => ({
|
||||
id,
|
||||
title,
|
||||
url: `/doc/${id}`,
|
||||
children,
|
||||
});
|
||||
|
||||
describe("buildBreadcrumb", () => {
|
||||
const structure: NavigationNode[] = [
|
||||
node("a", "Onboarding", [
|
||||
node("b", "Setup guide", [node("c", "Database")]),
|
||||
node("d", "Glossary"),
|
||||
]),
|
||||
node("e", "Architecture"),
|
||||
];
|
||||
|
||||
it("returns just the collection name for a root-level document", () => {
|
||||
expect(buildBreadcrumb("a", structure, "Engineering")).toBe("Engineering");
|
||||
expect(buildBreadcrumb("e", structure, "Engineering")).toBe("Engineering");
|
||||
});
|
||||
|
||||
it("includes ancestor titles for a nested document", () => {
|
||||
expect(buildBreadcrumb("b", structure, "Engineering")).toBe(
|
||||
"Engineering › Onboarding"
|
||||
);
|
||||
expect(buildBreadcrumb("c", structure, "Engineering")).toBe(
|
||||
"Engineering › Onboarding › Setup guide"
|
||||
);
|
||||
});
|
||||
|
||||
it("excludes the document's own title from the path", () => {
|
||||
const result = buildBreadcrumb("c", structure, "Engineering");
|
||||
expect(result).not.toContain("Database");
|
||||
});
|
||||
|
||||
it("falls back to the collection name when the document is not in the structure", () => {
|
||||
expect(buildBreadcrumb("missing", structure, "Engineering")).toBe(
|
||||
"Engineering"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns just the collection name when the structure is null", () => {
|
||||
expect(buildBreadcrumb("a", null, "Engineering")).toBe("Engineering");
|
||||
expect(buildBreadcrumb("a", undefined, "Engineering")).toBe("Engineering");
|
||||
});
|
||||
|
||||
it("returns just the collection name when the structure is empty", () => {
|
||||
expect(buildBreadcrumb("a", [], "Engineering")).toBe("Engineering");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBreadcrumbsForDocuments", () => {
|
||||
it("returns the collection name for a root-level document", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const collection = await buildCollection({
|
||||
teamId: team.id,
|
||||
permission: CollectionPermission.ReadWrite,
|
||||
name: "Engineering",
|
||||
});
|
||||
const doc = await buildDocument({
|
||||
teamId: team.id,
|
||||
collectionId: collection.id,
|
||||
});
|
||||
|
||||
const result = await getBreadcrumbsForDocuments([doc], user);
|
||||
expect(result.get(doc.id)).toBe("Engineering");
|
||||
});
|
||||
|
||||
it("includes ancestor titles for a nested document", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const collection = await buildCollection({
|
||||
teamId: team.id,
|
||||
permission: CollectionPermission.ReadWrite,
|
||||
name: "Engineering",
|
||||
});
|
||||
const parent = await buildDocument({
|
||||
teamId: team.id,
|
||||
collectionId: collection.id,
|
||||
title: "Onboarding",
|
||||
});
|
||||
const child = await buildDocument({
|
||||
teamId: team.id,
|
||||
collectionId: collection.id,
|
||||
parentDocumentId: parent.id,
|
||||
});
|
||||
|
||||
const result = await getBreadcrumbsForDocuments([child], user);
|
||||
expect(result.get(child.id)).toBe("Engineering › Onboarding");
|
||||
});
|
||||
|
||||
it("omits documents whose collection the user cannot read", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const collection = await buildCollection({
|
||||
teamId: team.id,
|
||||
permission: null,
|
||||
name: "Secrets",
|
||||
});
|
||||
const doc = await buildDocument({
|
||||
teamId: team.id,
|
||||
collectionId: collection.id,
|
||||
});
|
||||
|
||||
const result = await getBreadcrumbsForDocuments([doc], user);
|
||||
expect(result.has(doc.id)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns an empty map for empty input", async () => {
|
||||
const user = await buildUser();
|
||||
const result = await getBreadcrumbsForDocuments([], user);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it("omits documents that have no collection", async () => {
|
||||
const user = await buildUser();
|
||||
const result = await getBreadcrumbsForDocuments(
|
||||
[{ id: "doc-without-collection", collectionId: null }],
|
||||
user
|
||||
);
|
||||
expect(result.has("doc-without-collection")).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves breadcrumbs across multiple collections in one call", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const c1 = await buildCollection({
|
||||
teamId: team.id,
|
||||
permission: CollectionPermission.ReadWrite,
|
||||
name: "One",
|
||||
});
|
||||
const c2 = await buildCollection({
|
||||
teamId: team.id,
|
||||
permission: CollectionPermission.ReadWrite,
|
||||
name: "Two",
|
||||
});
|
||||
const d1 = await buildDocument({ teamId: team.id, collectionId: c1.id });
|
||||
const d2 = await buildDocument({ teamId: team.id, collectionId: c2.id });
|
||||
|
||||
const result = await getBreadcrumbsForDocuments([d1, d2], user);
|
||||
expect(result.get(d1.id)).toBe("One");
|
||||
expect(result.get(d2.id)).toBe("Two");
|
||||
});
|
||||
});
|
||||
+129
-1
@@ -1,8 +1,9 @@
|
||||
import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
|
||||
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||
import type { Team, User } from "@server/models";
|
||||
import { Collection, type Team, type User } from "@server/models";
|
||||
import { addTags } from "@server/logging/tracer";
|
||||
import { traceFunction } from "@server/logging/tracing";
|
||||
import { can } from "@server/policies";
|
||||
import { type APIContext, AuthenticationType } from "@server/types";
|
||||
import type { NavigationNode } from "@shared/types";
|
||||
|
||||
@@ -130,6 +131,133 @@ export function buildSiblingIndexMap(
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a human-readable breadcrumb string showing a document's location.
|
||||
* The path includes only ancestors (collection name plus any parent document
|
||||
* titles) — not the document itself, since callers already have the title.
|
||||
* Documents at the root of a collection get just the collection name.
|
||||
*
|
||||
* @param documentId - the ID of the document to locate.
|
||||
* @param structure - the collection's documentStructure tree, may be null.
|
||||
* @param collectionName - the name of the containing collection.
|
||||
* @returns the breadcrumb string, e.g. "Engineering › Onboarding".
|
||||
*/
|
||||
export function buildBreadcrumb(
|
||||
documentId: string,
|
||||
structure: NavigationNode[] | null | undefined,
|
||||
collectionName: string
|
||||
): string {
|
||||
const ancestors: string[] = [];
|
||||
|
||||
if (structure) {
|
||||
const findPath = (nodes: NavigationNode[], chain: string[]): boolean => {
|
||||
for (const node of nodes) {
|
||||
if (node.id === documentId) {
|
||||
ancestors.push(...chain);
|
||||
return true;
|
||||
}
|
||||
if (findPath(node.children, [...chain, node.title])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
findPath(structure, []);
|
||||
}
|
||||
|
||||
return [collectionName, ...ancestors].join(" › ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a breadcrumb string for a document by loading its collection's
|
||||
* cached documentStructure. Returns undefined when the document has no
|
||||
* collection, the collection cannot be loaded, or the user lacks read
|
||||
* access to the collection — the latter prevents leaking collection and
|
||||
* ancestor names to users granted access to a single nested document via
|
||||
* direct membership without wider collection access.
|
||||
*
|
||||
* @param document - the document to build a breadcrumb for.
|
||||
* @param user - the user performing the action, used to authorize collection access.
|
||||
* @returns the breadcrumb string, or undefined.
|
||||
*/
|
||||
export async function getDocumentBreadcrumb(
|
||||
document: { id: string; collectionId?: string | null },
|
||||
user: User
|
||||
): Promise<string | undefined> {
|
||||
if (!document.collectionId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const collection = await Collection.findByPk(document.collectionId, {
|
||||
userId: user.id,
|
||||
});
|
||||
if (!collection || !can(user, "read", collection)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const structure = await collection.getCachedDocumentStructure();
|
||||
return buildBreadcrumb(document.id, structure, collection.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves breadcrumb strings for a batch of documents in a single pass.
|
||||
* Loads all referenced collections (with the user's memberships) in one
|
||||
* query, filters by collection-level read access, then loads each
|
||||
* collection's cached documentStructure once.
|
||||
*
|
||||
* @param documents - the documents to build breadcrumbs for.
|
||||
* @param user - the user performing the action, used to authorize collection access.
|
||||
* @returns a map from document ID to breadcrumb string.
|
||||
*/
|
||||
export async function getBreadcrumbsForDocuments(
|
||||
documents: { id: string; collectionId?: string | null }[],
|
||||
user: User
|
||||
): Promise<Map<string, string>> {
|
||||
const breadcrumbs = new Map<string, string>();
|
||||
|
||||
const collectionIds = [
|
||||
...new Set(
|
||||
documents
|
||||
.map((doc) => doc.collectionId)
|
||||
.filter((id): id is string => !!id)
|
||||
),
|
||||
];
|
||||
if (collectionIds.length === 0) {
|
||||
return breadcrumbs;
|
||||
}
|
||||
|
||||
const collections = await Collection.scope([
|
||||
"defaultScope",
|
||||
{ method: ["withMembership", user.id] },
|
||||
]).findAll({
|
||||
where: { id: collectionIds },
|
||||
});
|
||||
|
||||
const collectionsById = new Map(
|
||||
collections
|
||||
.filter((collection) => can(user, "read", collection))
|
||||
.map((collection) => [collection.id, collection])
|
||||
);
|
||||
|
||||
for (const doc of documents) {
|
||||
if (!doc.collectionId) {
|
||||
continue;
|
||||
}
|
||||
const collection = collectionsById.get(doc.collectionId);
|
||||
if (!collection) {
|
||||
continue;
|
||||
}
|
||||
const structure = await collection.getCachedDocumentStructure();
|
||||
breadcrumbs.set(
|
||||
doc.id,
|
||||
buildBreadcrumb(doc.id, structure, collection.name)
|
||||
);
|
||||
}
|
||||
|
||||
return breadcrumbs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to construct a URL by joining a team URL with a path segment.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user