feat: MCP template support (#12639)

This commit is contained in:
Tom Moor
2026-06-09 07:43:21 -04:00
committed by GitHub
parent 95106e695f
commit bd01a62fc1
7 changed files with 417 additions and 8 deletions
+5 -1
View File
@@ -19,6 +19,7 @@ import { collectionTools } from "@server/tools/collections";
import { commentTools } from "@server/tools/comments"; import { commentTools } from "@server/tools/comments";
import { documentTools } from "@server/tools/documents"; import { documentTools } from "@server/tools/documents";
import { fetchTool } from "@server/tools/fetch"; import { fetchTool } from "@server/tools/fetch";
import { templateTools } from "@server/tools/templates";
import { userTools } from "@server/tools/users"; import { userTools } from "@server/tools/users";
import { version } from "../../../package.json"; import { version } from "../../../package.json";
@@ -29,7 +30,9 @@ const defaultInstructions = `Document markdown content must not begin with a top
Document and collection markdown support @mentions using the syntax: @[Display Name](mention://user/userId). For example: @[John Doe](mention://user/c9a1b2e3-...). Use the "list_users" tool to find user IDs. Document and collection markdown support @mentions using the syntax: @[Display Name](mention://user/userId). For example: @[John Doe](mention://user/c9a1b2e3-...). Use the "list_users" tool to find user IDs.
Read images and attachments with the "fetch" tool by setting resource to "attachment" and passing either the attachment ID or an /api/attachments.redirect?id=... URL; the tool will return a signed URL for download.`; Read images and attachments with the "fetch" tool by setting resource to "attachment" and passing either the attachment ID or an /api/attachments.redirect?id=... URL; the tool will return a signed URL for download.
When asked to create a document that follows a template, use the "list_templates" tool to find a matching template; each result already includes the template body as markdown. To use it unchanged, pass its ID as templateId to "create_document" and the new document is pre-filled from it. To adapt it first, modify the returned body and pass the result as the text parameter to "create_document". Either way no separate fetch is needed.`;
/** /**
* Creates a fresh MCP server instance with tools filtered by the OAuth * Creates a fresh MCP server instance with tools filtered by the OAuth
@@ -62,6 +65,7 @@ function createMcpServer(scopes: string[], guidance?: string): McpServer {
commentTools(server, scopes); commentTools(server, scopes);
documentTools(server, scopes); documentTools(server, scopes);
fetchTool(server, scopes); fetchTool(server, scopes);
templateTools(server, scopes);
userTools(server, scopes); userTools(server, scopes);
return server; return server;
+77
View File
@@ -4,6 +4,7 @@ import {
buildViewer, buildViewer,
buildCollection, buildCollection,
buildDocument, buildDocument,
buildTemplate,
buildOAuthAuthentication, buildOAuthAuthentication,
} from "@server/test/factories"; } from "@server/test/factories";
import { Document } from "@server/models"; import { Document } from "@server/models";
@@ -189,6 +190,82 @@ describe("create_document", () => {
expect(data.document.parentDocumentId).toEqual(parent.id); expect(data.document.parentDocumentId).toEqual(parent.id);
}); });
it("creates from a template", async () => {
const { user, accessToken } = await buildOAuthUser();
const collection = await buildCollection({
teamId: user.teamId,
userId: user.id,
});
const template = await buildTemplate({
teamId: user.teamId,
userId: user.id,
collectionId: collection.id,
text: "Content from the template",
});
const res = await callMcpTool(server, accessToken, "create_document", {
title: "From Template",
collectionId: collection.id,
templateId: template.id,
});
const data = JSON.parse(res?.result?.content?.[0]?.text ?? "{}");
const text = res?.result?.content?.[1]?.text ?? "";
expect(res?.result?.isError).not.toBe(true);
expect(data.document.title).toEqual("From Template");
expect(data.document.templateId).toEqual(template.id);
expect(text).toContain("Content from the template");
});
it("defaults the title to the template title", async () => {
const { user, accessToken } = await buildOAuthUser();
const collection = await buildCollection({
teamId: user.teamId,
userId: user.id,
});
const template = await buildTemplate({
teamId: user.teamId,
userId: user.id,
collectionId: collection.id,
title: "Template Title",
});
const res = await callMcpTool(server, accessToken, "create_document", {
collectionId: collection.id,
templateId: template.id,
});
const data = JSON.parse(res?.result?.content?.[0]?.text ?? "{}");
expect(res?.result?.isError).not.toBe(true);
expect(data.document.title).toEqual("Template Title");
});
it("does not allow creating from a template the user cannot access", async () => {
const { user, accessToken } = await buildOAuthUser();
const collection = await buildCollection({
teamId: user.teamId,
userId: user.id,
});
const otherUser = await buildUser();
const otherCollection = await buildCollection({
teamId: otherUser.teamId,
userId: otherUser.id,
});
const template = await buildTemplate({
teamId: otherUser.teamId,
userId: otherUser.id,
collectionId: otherCollection.id,
});
const res = await callMcpTool(server, accessToken, "create_document", {
title: "From Template",
collectionId: collection.id,
templateId: template.id,
});
expect(res?.result?.isError).toBe(true);
});
it("does not allow a viewer to create a draft", async () => { it("does not allow a viewer to create a draft", async () => {
const user = await buildViewer(); const user = await buildViewer();
const auth = await buildOAuthAuthentication({ const auth = await buildOAuthAuthentication({
+18 -4
View File
@@ -9,7 +9,7 @@ import documentMover from "@server/commands/documentMover";
import documentRestorer from "@server/commands/documentRestorer"; import documentRestorer from "@server/commands/documentRestorer";
import documentUpdater from "@server/commands/documentUpdater"; import documentUpdater from "@server/commands/documentUpdater";
import { Op } from "sequelize"; import { Op } from "sequelize";
import { Collection, Document } from "@server/models"; import { Collection, Document, Template } from "@server/models";
import { sequelize } from "@server/storage/database"; import { sequelize } from "@server/storage/database";
import { authorize, can } from "@server/policies"; import { authorize, can } from "@server/policies";
import { import {
@@ -301,13 +301,15 @@ export function documentTools(server: McpServer, scopes: string[]) {
{ {
title: "Create document", title: "Create document",
description: description:
"Creates a new document. Requires a collectionId to place the document in a collection, or parentDocumentId to nest it under an existing document.", "Creates a new document. Requires a collectionId to place the document in a collection, or parentDocumentId to nest it under an existing document. Pass a templateId (from list_templates) to pre-fill the document from a template; the template's content is used unless text is also provided.",
annotations: { annotations: {
idempotentHint: false, idempotentHint: false,
readOnlyHint: false, readOnlyHint: false,
}, },
inputSchema: { inputSchema: {
title: z.string().describe("The title of the document."), title: optionalString().describe(
"The title of the document. Defaults to the template's title when a templateId is provided."
),
text: z text: z
.string() .string()
.optional() .optional()
@@ -318,6 +320,9 @@ export function documentTools(server: McpServer, scopes: string[]) {
parentDocumentId: optionalString().describe( parentDocumentId: optionalString().describe(
"The parent document ID to nest this document under." "The parent document ID to nest this document under."
), ),
templateId: optionalString().describe(
"The ID of a template to pre-fill the new document from. The template's title, content, icon, and color are used unless overridden by the corresponding parameters."
),
icon: optionalString().describe( icon: optionalString().describe(
"An icon for the document, e.g. an emoji." "An icon for the document, e.g. an emoji."
), ),
@@ -340,7 +345,7 @@ export function documentTools(server: McpServer, scopes: string[]) {
}, },
withTracing("create_document", async (input, context) => { withTracing("create_document", async (input, context) => {
try { try {
const { collectionId, parentDocumentId } = input; const { collectionId, parentDocumentId, templateId } = input;
const ctx = buildAPIContext(context); const ctx = buildAPIContext(context);
const { user } = ctx.state.auth; const { user } = ctx.state.auth;
@@ -349,6 +354,14 @@ export function documentTools(server: McpServer, scopes: string[]) {
parentDocumentId, parentDocumentId,
}); });
let template: Template | null | undefined;
if (templateId) {
template = await Template.findByPk(templateId, {
userId: user.id,
});
authorize(user, "read", template);
}
const document = await documentCreator(ctx, { const document = await documentCreator(ctx, {
title: input.title, title: input.title,
text: input.text, text: input.text,
@@ -357,6 +370,7 @@ export function documentTools(server: McpServer, scopes: string[]) {
parentDocumentId: parentDocumentId, parentDocumentId: parentDocumentId,
publish: input.publish !== false, publish: input.publish !== false,
collectionId: collection?.id, collectionId: collection?.id,
template,
fullWidth: input.fullWidth, fullWidth: input.fullWidth,
}); });
+29
View File
@@ -3,6 +3,7 @@ import {
buildComment, buildComment,
buildDocument, buildDocument,
buildResolvedComment, buildResolvedComment,
buildTemplate,
} from "@server/test/factories"; } from "@server/test/factories";
import { getTestServer } from "@server/test/support"; import { getTestServer } from "@server/test/support";
import { buildOAuthUser, callMcpTool } from "@server/test/McpHelper"; import { buildOAuthUser, callMcpTool } from "@server/test/McpHelper";
@@ -94,4 +95,32 @@ describe("fetch", () => {
const metadata = JSON.parse(res!.result!.content![0].text ?? "{}"); const metadata = JSON.parse(res!.result!.content![0].text ?? "{}");
expect(metadata.document.commentCount).toEqual(2); expect(metadata.document.commentCount).toEqual(2);
}); });
it("returns template metadata and markdown", async () => {
const { user, accessToken } = await buildOAuthUser();
const collection = await buildCollection({
teamId: user.teamId,
userId: user.id,
});
const template = await buildTemplate({
teamId: user.teamId,
userId: user.id,
collectionId: collection.id,
text: "Body of the template",
});
const res = await callMcpTool(server, accessToken, "fetch", {
resource: "template",
id: template.id,
});
expect(res?.result?.isError).not.toBe(true);
expect(res!.result!.content!.length).toEqual(2);
const metadata = JSON.parse(res!.result!.content![0].text ?? "{}");
expect(metadata.id).toEqual(template.id);
expect(metadata.url).toMatch(/^https?:\/\//);
expect(res!.result!.content![1].text).toContain("Body of the template");
});
}); });
+39 -3
View File
@@ -1,7 +1,13 @@
import { z } from "zod"; import { z } from "zod";
import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { type CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { type CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { Attachment, Collection, Document, User } from "@server/models"; import {
Attachment,
Collection,
Document,
Template,
User,
} from "@server/models";
import { authorize, can } from "@server/policies"; import { authorize, can } from "@server/policies";
import { AuthorizationError } from "@server/errors"; import { AuthorizationError } from "@server/errors";
import { import {
@@ -11,6 +17,7 @@ import {
} from "@server/presenters"; } from "@server/presenters";
import AuthenticationHelper from "@shared/helpers/AuthenticationHelper"; import AuthenticationHelper from "@shared/helpers/AuthenticationHelper";
import { presentDocument } from "./documents"; import { presentDocument } from "./documents";
import { presentTemplate } from "./templates";
import { import {
error, error,
success, success,
@@ -68,12 +75,17 @@ export function fetchTool(server: McpServer, scopes: string[]) {
"attachments.info", "attachments.info",
scopes scopes
); );
const canReadTemplates = AuthenticationHelper.canAccess(
"templates.info",
scopes
);
if ( if (
!canReadDocuments && !canReadDocuments &&
!canReadCollections && !canReadCollections &&
!canReadUsers && !canReadUsers &&
!canReadAttachments !canReadAttachments &&
!canReadTemplates
) { ) {
return; return;
} }
@@ -83,6 +95,7 @@ export function fetchTool(server: McpServer, scopes: string[]) {
...(canReadCollections ? ["collection"] : []), ...(canReadCollections ? ["collection"] : []),
...(canReadUsers ? ["user"] : []), ...(canReadUsers ? ["user"] : []),
...(canReadAttachments ? ["attachment"] : []), ...(canReadAttachments ? ["attachment"] : []),
...(canReadTemplates ? ["template"] : []),
] as [string, ...string[]]; ] as [string, ...string[]];
server.registerTool( server.registerTool(
@@ -90,7 +103,7 @@ export function fetchTool(server: McpServer, scopes: string[]) {
{ {
title: "Fetch", title: "Fetch",
description: description:
'Fetches a document, collection, user, or attachment 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. For attachments, the response includes a short-lived signed URL that can be used to download the file contents directly.', 'Fetches a document, collection, user, attachment, or template 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. For attachments, the response includes a short-lived signed URL that can be used to download the file contents directly. For templates, the response includes the template body as markdown.',
annotations: { annotations: {
idempotentHint: true, idempotentHint: true,
readOnlyHint: true, readOnlyHint: true,
@@ -198,6 +211,29 @@ export function fetchTool(server: McpServer, scopes: string[]) {
}); });
} }
case "template": {
const template = await Template.findByPk(id, {
userId: actor.id,
rejectOnEmpty: true,
});
authorize(actor, "read", template);
const { text, ...attributes } = await presentTemplate(template);
return {
content: [
{
type: "text" as const,
text: JSON.stringify(pathToUrl(actor.team, attributes)),
},
{
type: "text" as const,
text,
},
],
} satisfies CallToolResult;
}
default: default:
return error(`Unknown resource: ${resource}`); return error(`Unknown resource: ${resource}`);
} }
+115
View File
@@ -0,0 +1,115 @@
import { Scope } from "@shared/types";
import {
buildUser,
buildCollection,
buildTemplate,
buildOAuthAuthentication,
} from "@server/test/factories";
import { getTestServer } from "@server/test/support";
import { buildOAuthUser, callMcpTool } from "@server/test/McpHelper";
const server = getTestServer();
describe("list_templates", () => {
it("returns workspace and collection templates the user can access", async () => {
const { user, accessToken } = await buildOAuthUser();
const collection = await buildCollection({
teamId: user.teamId,
userId: user.id,
});
const workspaceTemplate = await buildTemplate({
teamId: user.teamId,
userId: user.id,
collectionId: null,
text: "Body of the workspace template",
});
const collectionTemplate = await buildTemplate({
teamId: user.teamId,
userId: user.id,
collectionId: collection.id,
});
const res = await callMcpTool(server, accessToken, "list_templates");
const data = (res?.result?.content ?? []).map((c: { text?: string }) =>
JSON.parse(c.text ?? "{}")
);
const ids = data.map((t: { id: string }) => t.id);
expect(ids).toContain(workspaceTemplate.id);
expect(ids).toContain(collectionTemplate.id);
const match = data.find(
(t: { id: string }) => t.id === workspaceTemplate.id
) as { url: string; collectionId: string | null; text: string };
expect(match.url).toMatch(/^https?:\/\//);
expect(match.collectionId).toBeNull();
expect(match.text).toContain("Body of the workspace template");
});
it("filters by collection", async () => {
const { user, accessToken } = await buildOAuthUser();
const collection1 = await buildCollection({
teamId: user.teamId,
userId: user.id,
});
const collection2 = await buildCollection({
teamId: user.teamId,
userId: user.id,
});
const template1 = await buildTemplate({
teamId: user.teamId,
userId: user.id,
collectionId: collection1.id,
});
await buildTemplate({
teamId: user.teamId,
userId: user.id,
collectionId: collection2.id,
});
const res = await callMcpTool(server, accessToken, "list_templates", {
collectionId: collection1.id,
});
const data = (res?.result?.content ?? []).map((c: { text?: string }) =>
JSON.parse(c.text ?? "{}")
);
const ids = data.map((t: { id: string }) => t.id);
expect(ids).toContain(template1.id);
expect(
data.every(
(t: { collectionId: string }) => t.collectionId === collection1.id
)
).toBe(true);
});
it("does not return templates from collections the user cannot access", async () => {
const owner = await buildUser();
const otherUser = await buildUser({ teamId: owner.teamId });
const privateCollection = await buildCollection({
teamId: owner.teamId,
userId: owner.id,
permission: null,
});
const privateTemplate = await buildTemplate({
teamId: owner.teamId,
userId: owner.id,
collectionId: privateCollection.id,
});
const auth = await buildOAuthAuthentication({
user: otherUser,
scope: [Scope.Read],
});
const res = await callMcpTool(server, auth.accessToken!, "list_templates");
const data = (res?.result?.content ?? []).map((c: { text?: string }) =>
JSON.parse(c.text ?? "{}")
);
const ids = data.map((t: { id: string }) => t.id);
expect(res?.result?.isError).not.toBe(true);
expect(ids).not.toContain(privateTemplate.id);
});
});
+134
View File
@@ -0,0 +1,134 @@
import { z } from "zod";
import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { Op } from "sequelize";
import type { WhereOptions } from "sequelize";
import { Collection, Template } from "@server/models";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import { authorize } from "@server/policies";
import AuthenticationHelper from "@shared/helpers/AuthenticationHelper";
import {
error,
success,
getActorFromContext,
optionalString,
pathToUrl,
withTracing,
} from "./util";
/**
* Presents a template's metadata and rendered markdown body for a tool
* response. Including the body lets a caller list templates and create a
* document from one — verbatim or adapted — without a separate fetch call.
*
* @param template - the template to present.
* @returns the presented template with its body as markdown.
*/
export async function presentTemplate(template: Template) {
return {
id: template.id,
url: template.path,
title: template.title,
collectionId: template.collectionId ?? null,
updatedAt: template.updatedAt,
text: template.content
? await DocumentHelper.toMarkdown(template.content, {
includeTitle: false,
})
: "",
};
}
/**
* Registers template-related MCP tools on the given server, filtered by the
* OAuth scopes granted to the current token.
*
* @param server - the MCP server instance to register on.
* @param scopes - the OAuth scopes granted to the access token.
*/
export function templateTools(server: McpServer, scopes: string[]) {
if (AuthenticationHelper.canAccess("templates.list", scopes)) {
server.registerTool(
"list_templates",
{
title: "List templates",
description:
"Lists document templates the user has access to, including workspace-wide templates and templates within accessible collections. Each result includes the template body as markdown. To create a document from a template unchanged, pass its ID as templateId to create_document. To adapt it first, modify the returned text and pass it as the text parameter to create_document — no separate fetch is needed.",
annotations: {
idempotentHint: true,
readOnlyHint: true,
},
inputSchema: {
collectionId: optionalString().describe(
"A collection ID to filter templates by. Omit to include workspace-wide templates and templates from all accessible collections."
),
offset: z.coerce
.number()
.int()
.min(0)
.optional()
.describe("The pagination offset. Defaults to 0."),
limit: z.coerce
.number()
.int()
.min(1)
.max(100)
.optional()
.describe(
"The maximum number of results to return. Defaults to 25, max 100."
),
},
},
withTracing(
"list_templates",
async ({ collectionId, offset, limit }, extra) => {
try {
const user = getActorFromContext(extra);
const effectiveOffset = offset ?? 0;
const effectiveLimit = limit ?? 25;
const where: WhereOptions<Template> & {
[Op.and]: WhereOptions<Template>[];
} = {
teamId: user.teamId,
[Op.and]: [{ deletedAt: { [Op.eq]: null } }],
};
if (collectionId) {
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
});
authorize(user, "read", collection);
where[Op.and].push({ collectionId });
} else {
where[Op.and].push({
[Op.or]: [
{ collectionId: { [Op.eq]: null } },
{ collectionId: await user.collectionIds() },
],
});
}
const templates = await Template.scope([
"defaultScope",
{ method: ["withMembership", user.id] },
]).findAll({
where,
order: [["updatedAt", "DESC"]],
offset: effectiveOffset,
limit: effectiveLimit,
});
const presented = await Promise.all(
templates.map(async (template) =>
pathToUrl(user.team, await presentTemplate(template))
)
);
return success(presented);
} catch (message) {
return error(message);
}
}
)
);
}
}