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 { documentTools } from "@server/tools/documents";
import { fetchTool } from "@server/tools/fetch";
import { templateTools } from "@server/tools/templates";
import { userTools } from "@server/tools/users";
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.
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
@@ -62,6 +65,7 @@ function createMcpServer(scopes: string[], guidance?: string): McpServer {
commentTools(server, scopes);
documentTools(server, scopes);
fetchTool(server, scopes);
templateTools(server, scopes);
userTools(server, scopes);
return server;
+77
View File
@@ -4,6 +4,7 @@ import {
buildViewer,
buildCollection,
buildDocument,
buildTemplate,
buildOAuthAuthentication,
} from "@server/test/factories";
import { Document } from "@server/models";
@@ -189,6 +190,82 @@ describe("create_document", () => {
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 () => {
const user = await buildViewer();
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 documentUpdater from "@server/commands/documentUpdater";
import { Op } from "sequelize";
import { Collection, Document } from "@server/models";
import { Collection, Document, Template } from "@server/models";
import { sequelize } from "@server/storage/database";
import { authorize, can } from "@server/policies";
import {
@@ -301,13 +301,15 @@ export function documentTools(server: McpServer, scopes: string[]) {
{
title: "Create document",
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: {
idempotentHint: false,
readOnlyHint: false,
},
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
.string()
.optional()
@@ -318,6 +320,9 @@ export function documentTools(server: McpServer, scopes: string[]) {
parentDocumentId: optionalString().describe(
"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(
"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) => {
try {
const { collectionId, parentDocumentId } = input;
const { collectionId, parentDocumentId, templateId } = input;
const ctx = buildAPIContext(context);
const { user } = ctx.state.auth;
@@ -349,6 +354,14 @@ export function documentTools(server: McpServer, scopes: string[]) {
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, {
title: input.title,
text: input.text,
@@ -357,6 +370,7 @@ export function documentTools(server: McpServer, scopes: string[]) {
parentDocumentId: parentDocumentId,
publish: input.publish !== false,
collectionId: collection?.id,
template,
fullWidth: input.fullWidth,
});
+29
View File
@@ -3,6 +3,7 @@ import {
buildComment,
buildDocument,
buildResolvedComment,
buildTemplate,
} from "@server/test/factories";
import { getTestServer } from "@server/test/support";
import { buildOAuthUser, callMcpTool } from "@server/test/McpHelper";
@@ -94,4 +95,32 @@ describe("fetch", () => {
const metadata = JSON.parse(res!.result!.content![0].text ?? "{}");
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 { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.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 { AuthorizationError } from "@server/errors";
import {
@@ -11,6 +17,7 @@ import {
} from "@server/presenters";
import AuthenticationHelper from "@shared/helpers/AuthenticationHelper";
import { presentDocument } from "./documents";
import { presentTemplate } from "./templates";
import {
error,
success,
@@ -68,12 +75,17 @@ export function fetchTool(server: McpServer, scopes: string[]) {
"attachments.info",
scopes
);
const canReadTemplates = AuthenticationHelper.canAccess(
"templates.info",
scopes
);
if (
!canReadDocuments &&
!canReadCollections &&
!canReadUsers &&
!canReadAttachments
!canReadAttachments &&
!canReadTemplates
) {
return;
}
@@ -83,6 +95,7 @@ export function fetchTool(server: McpServer, scopes: string[]) {
...(canReadCollections ? ["collection"] : []),
...(canReadUsers ? ["user"] : []),
...(canReadAttachments ? ["attachment"] : []),
...(canReadTemplates ? ["template"] : []),
] as [string, ...string[]];
server.registerTool(
@@ -90,7 +103,7 @@ export function fetchTool(server: McpServer, scopes: string[]) {
{
title: "Fetch",
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: {
idempotentHint: 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:
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);
}
}
)
);
}
}