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:
Tom Moor
2026-05-01 22:57:03 -04:00
committed by GitHub
parent 621089a364
commit 8a896ddd2d
7 changed files with 436 additions and 113 deletions
+26 -1
View File
@@ -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;
+1 -14
View File
@@ -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 || [],
+30 -29
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+158
View File
@@ -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
View File
@@ -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.
*