From bd01a62fc1902c5ebad36b7eabf8ae0ad21e1e5a Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Tue, 9 Jun 2026 07:43:21 -0400 Subject: [PATCH] feat: MCP template support (#12639) --- server/routes/mcp/index.ts | 6 +- server/tools/documents.test.ts | 77 +++++++++++++++++++ server/tools/documents.ts | 22 +++++- server/tools/fetch.test.ts | 29 +++++++ server/tools/fetch.ts | 42 ++++++++++- server/tools/templates.test.ts | 115 ++++++++++++++++++++++++++++ server/tools/templates.ts | 134 +++++++++++++++++++++++++++++++++ 7 files changed, 417 insertions(+), 8 deletions(-) create mode 100644 server/tools/templates.test.ts create mode 100644 server/tools/templates.ts diff --git a/server/routes/mcp/index.ts b/server/routes/mcp/index.ts index c80aa1f58c..3adbe198c1 100644 --- a/server/routes/mcp/index.ts +++ b/server/routes/mcp/index.ts @@ -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; diff --git a/server/tools/documents.test.ts b/server/tools/documents.test.ts index 160731c16f..d88e3cecac 100644 --- a/server/tools/documents.test.ts +++ b/server/tools/documents.test.ts @@ -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({ diff --git a/server/tools/documents.ts b/server/tools/documents.ts index e80be278c1..42dbf4f2ca 100644 --- a/server/tools/documents.ts +++ b/server/tools/documents.ts @@ -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, }); diff --git a/server/tools/fetch.test.ts b/server/tools/fetch.test.ts index ddf94b7561..2f2c72e97a 100644 --- a/server/tools/fetch.test.ts +++ b/server/tools/fetch.test.ts @@ -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"); + }); }); diff --git a/server/tools/fetch.ts b/server/tools/fetch.ts index c3acd98c66..2f18d197c1 100644 --- a/server/tools/fetch.ts +++ b/server/tools/fetch.ts @@ -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}`); } diff --git a/server/tools/templates.test.ts b/server/tools/templates.test.ts new file mode 100644 index 0000000000..56941ec273 --- /dev/null +++ b/server/tools/templates.test.ts @@ -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); + }); +}); diff --git a/server/tools/templates.ts b/server/tools/templates.ts new file mode 100644 index 0000000000..35558608db --- /dev/null +++ b/server/tools/templates.ts @@ -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