Files

332 lines
10 KiB
TypeScript

import { z } from "zod";
import { Sequelize, Op, type WhereOptions } from "sequelize";
import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { Collection, Team } from "@server/models";
import { sequelize } from "@server/storage/database";
import { authorize } from "@server/policies";
import { presentCollection } from "@server/presenters";
import AuthenticationHelper from "@shared/helpers/AuthenticationHelper";
import { UrlHelper } from "@shared/utils/UrlHelper";
import {
success,
error,
getActorFromContext,
buildAPIContext,
optionalString,
pathToUrl,
withTracing,
} from "./util";
/**
* Registers collection-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 collectionTools(server: McpServer, scopes: string[]) {
if (AuthenticationHelper.canAccess("collections.list", scopes)) {
server.registerTool(
"list_collections",
{
title: "List collections",
description:
"Lists all collections the authenticated user has access to. Returns a summary of each collection.",
annotations: {
idempotentHint: true,
readOnlyHint: true,
},
inputSchema: {
query: optionalString().describe(
"An optional search query to filter collections by name."
),
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_collections",
async ({ query, offset, limit }, extra) => {
try {
const user = getActorFromContext(extra);
const collectionIds = await user.collectionIds();
const and: WhereOptions<Collection>[] = [
{ deletedAt: { [Op.eq]: null } },
{ archivedAt: { [Op.eq]: null } },
{ id: collectionIds },
];
if (query) {
and.push(
Sequelize.literal(
`unaccent(LOWER(name)) like unaccent(LOWER(:query))`
) as unknown as WhereOptions<Collection>
);
}
const where: WhereOptions<Collection> = {
teamId: user.teamId,
[Op.and]: and,
};
const collections = await Collection.scope({
method: ["withMembership", user.id],
}).findAll({
where,
replacements: { query: `%${query}%` },
order: [
Sequelize.literal('"collection"."index" collate "C"'),
["updatedAt", "DESC"],
],
offset: offset ?? 0,
limit: limit ?? 25,
});
// If the query looks like a collection ID or urlId, try direct
// lookup first so exact matches appear at the top of results.
let exactMatch: Collection | null = null;
if (query && UrlHelper.SLUG_URL_REGEX.test(query)) {
exactMatch = await Collection.findByPk(query, {
userId: user.id,
});
if (exactMatch && !collectionIds.includes(exactMatch.id)) {
exactMatch = null;
}
}
const presented = await Promise.all(
collections
.filter((c) => c.id !== exactMatch?.id)
.map(async (collection) =>
pathToUrl(
user.team,
await presentCollection(undefined, collection)
)
)
);
if (exactMatch) {
presented.unshift(
pathToUrl(
user.team,
await presentCollection(undefined, exactMatch)
)
);
}
return success(presented);
} catch (message) {
return error(message);
}
}
)
);
}
if (AuthenticationHelper.canAccess("collections.create", scopes)) {
server.registerTool(
"create_collection",
{
title: "Create collection",
description:
"Creates a new collection. Collections are used to organize documents.",
annotations: {
idempotentHint: false,
readOnlyHint: false,
},
inputSchema: {
name: z.string().describe("The name of the collection."),
description: z
.string()
.optional()
.describe("A markdown description for the collection."),
icon: optionalString().describe(
"An icon for the collection, e.g. an emoji."
),
color: optionalString().describe(
"The hex color for the collection icon, e.g. #FF0000."
),
},
},
withTracing("create_collection", async (input, context) => {
try {
const ctx = buildAPIContext(context);
const { user } = ctx.state.auth;
const team = await Team.findByPk(user.teamId, {
rejectOnEmpty: true,
});
authorize(user, "createCollection", team);
const collection = Collection.build({
name: input.name,
description: input.description,
icon: input.icon,
color: input.color,
teamId: user.teamId,
createdById: user.id,
permission: null,
});
await collection.saveWithCtx(ctx);
const reloaded = await Collection.findByPk(collection.id, {
userId: user.id,
rejectOnEmpty: true,
});
const presented = pathToUrl(
user.team,
await presentCollection(undefined, reloaded)
);
return success(presented);
} catch (message) {
return error(message);
}
})
);
}
if (AuthenticationHelper.canAccess("collections.update", scopes)) {
server.registerTool(
"update_collection",
{
title: "Update collection",
description:
"Updates an existing collection by its ID. Only the fields provided will be updated.",
annotations: {
idempotentHint: true,
readOnlyHint: false,
},
inputSchema: {
id: z
.string()
.describe("The unique identifier of the collection to update."),
name: optionalString().describe("The new name for the collection."),
description: z
.string()
.optional()
.describe("The new markdown description for the collection."),
icon: z
.string()
.nullable()
.optional()
.describe(
"An icon for the collection, e.g. an emoji. Set to null to remove."
),
color: z
.string()
.nullable()
.optional()
.describe(
"The hex color for the collection icon. Set to null to remove."
),
},
},
withTracing("update_collection", async (input, context) => {
try {
const ctx = buildAPIContext(context);
const { user } = ctx.state.auth;
const collection = await Collection.findByPk(input.id, {
userId: user.id,
rejectOnEmpty: true,
});
authorize(user, "update", collection);
if (input.name !== undefined) {
collection.name = input.name.trim();
}
if (input.description !== undefined) {
collection.description = input.description;
}
if (input.icon !== undefined) {
collection.icon = input.icon;
}
if (input.color !== undefined) {
collection.color = input.color;
}
await collection.saveWithCtx(ctx);
const presented = pathToUrl(
user.team,
await presentCollection(undefined, collection)
);
return success(presented);
} catch (message) {
return error(message);
}
})
);
}
if (AuthenticationHelper.canAccess("collections.delete", scopes)) {
server.registerTool(
"delete_collection",
{
title: "Delete collection",
description:
"Deletes a collection by its ID. Non-archived documents within the collection will also be deleted. Set archive to true to archive the collection instead of deleting it.",
annotations: {
idempotentHint: false,
readOnlyHint: false,
},
inputSchema: {
id: z
.string()
.describe("The unique identifier of the collection to delete."),
archive: z
.boolean()
.optional()
.describe(
"Set to true to archive the collection instead of deleting it. All documents within the collection will also be archived."
),
},
},
withTracing("delete_collection", async ({ id, archive }, context) => {
try {
const ctx = buildAPIContext(context);
const { user } = ctx.state.auth;
await sequelize.transaction(async (transaction) => {
ctx.state.transaction = transaction;
ctx.context.transaction = transaction;
const collection = await Collection.findByPk(id, {
userId: user.id,
rejectOnEmpty: true,
transaction,
});
if (archive) {
authorize(user, "archive", collection);
await collection.archiveWithCtx(ctx);
} else {
authorize(user, "delete", collection);
await collection.destroyWithCtx(ctx);
}
});
return success({ success: true });
} catch (message) {
return error(message);
}
})
);
}
}