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 { 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;
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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