mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
feat: MCP template support (#12639)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user