import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination" import { and, asc, eq, ilike, isNull } from "drizzle-orm" import { wikiPages } from "../../../db/schema" import { McpContext, McpTool } from "../types" const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i type WikiScope = { entityType: string | null entityId: number | null entityUuid: string | null } const stringArg = (args: Record, key: string) => { const value = args[key] return typeof value === "string" && value.trim() ? value.trim() : null } const uuidArg = (args: Record, key: string) => { const value = stringArg(args, key) if (!value || !UUID_PATTERN.test(value)) return null return value } const pageIdArg = (args: Record, key = "id") => { const value = uuidArg(args, key) if (!value) throw new Error(`${key} muss eine gültige UUID sein`) return value } export function prepareWikiScope(args: Record): WikiScope { const entityType = stringArg(args, "entityType") const hasEntityId = args.entityId !== undefined && args.entityId !== null const hasEntityUuid = args.entityUuid !== undefined && args.entityUuid !== null if (!entityType && (hasEntityId || hasEntityUuid)) { throw new Error("entityType ist für ein Entitäts-Wiki erforderlich") } if (entityType && !hasEntityId && !hasEntityUuid) { throw new Error("Für ein Entitäts-Wiki ist entityId oder entityUuid erforderlich") } if (hasEntityId && hasEntityUuid) { throw new Error("Bitte entweder entityId oder entityUuid angeben, nicht beides") } if (!entityType) { return { entityType: null, entityId: null, entityUuid: null } } if (hasEntityId) { const entityId = Number(args.entityId) if (!Number.isSafeInteger(entityId) || entityId <= 0) { throw new Error("entityId muss eine positive ganze Zahl sein") } return { entityType, entityId, entityUuid: null } } const entityUuid = uuidArg(args, "entityUuid") if (!entityUuid) throw new Error("entityUuid muss eine gültige UUID sein") return { entityType, entityId: null, entityUuid } } const scopeConditions = (scope: WikiScope) => [ scope.entityType === null ? isNull(wikiPages.entityType) : eq(wikiPages.entityType, scope.entityType), scope.entityId === null ? isNull(wikiPages.entityId) : eq(wikiPages.entityId, scope.entityId), scope.entityUuid === null ? isNull(wikiPages.entityUuid) : eq(wikiPages.entityUuid, scope.entityUuid), ] const scopeFromPage = (page: { entityType: string | null entityId: number | null entityUuid: string | null }): WikiScope => ({ entityType: page.entityType, entityId: page.entityId, entityUuid: page.entityUuid, }) const sameScope = (left: WikiScope, right: WikiScope) => left.entityType === right.entityType && left.entityId === right.entityId && left.entityUuid === right.entityUuid async function loadPage(context: McpContext, id: string) { const [page] = await context.server.db .select() .from(wikiPages) .where(and(eq(wikiPages.id, id), eq(wikiPages.tenantId, context.tenantId))) .limit(1) if (!page) throw new Error("Wiki-Seite nicht gefunden") return page } async function assertValidParent( context: McpContext, parentId: string | null, scope: WikiScope, pageId?: string, ) { if (!parentId) return if (parentId === pageId) throw new Error("Eine Wiki-Seite kann nicht ihr eigener übergeordneter Eintrag sein") const parent = await loadPage(context, parentId) if (!parent.isFolder) throw new Error("Der übergeordnete Wiki-Eintrag muss ein Ordner sein") if (!sameScope(scopeFromPage(parent), scope)) { throw new Error("Übergeordneter Eintrag und Wiki-Seite müssen zum selben Wiki gehören") } } const entityScopeProperties = { entityType: { type: "string", description: "Technischer Entitätstyp, z. B. customers oder projects. Ohne Angabe wird das allgemeine Wiki verwendet." }, entityId: { type: "number", description: "Numerische ID der Entität; nur zusammen mit entityType und alternativ zu entityUuid." }, entityUuid: { type: "string", format: "uuid", description: "UUID der Entität; nur zusammen mit entityType und alternativ zu entityId." }, } const pageProperties = { title: { type: "string", description: "Titel der Wiki-Seite oder des Ordners." }, content: { description: "Inhalt als JSON, üblicherweise ein Tiptap/ProseMirror-Dokument." }, parentId: { type: ["string", "null"], format: "uuid", description: "Übergeordneter Ordner; null verschiebt den Eintrag auf die oberste Ebene." }, sortOrder: { type: "integer", description: "Sortierreihenfolge innerhalb derselben Ebene." }, isFolder: { type: "boolean", description: "Kennzeichnet den Eintrag als Ordner." }, } export const wikiTools: McpTool[] = [ { name: "wiki.pages.list", title: "Wiki-Seiten auflisten", description: "Listet Seiten und Ordner des allgemeinen Wikis oder des Wikis einer einzelnen Entität. Der Inhalt wird nicht mitgeladen; dafür wiki.pages.get verwenden.", requiredPermissions: ["wiki.read"], inputSchema: { type: "object", properties: { ...entityScopeProperties, query: { type: "string", description: "Optionaler Suchtext im Titel." }, parentId: { type: ["string", "null"], format: "uuid", description: "Optional auf direkte Kinder dieses Ordners einschränken; null steht für die oberste Ebene." }, ...paginationProperties, limit: { ...paginationProperties.limit, default: 50 }, }, }, async handler(context, args) { const scope = prepareWikiScope(args) const conditions = [eq(wikiPages.tenantId, context.tenantId), ...scopeConditions(scope)] const query = stringArg(args, "query") if (query) conditions.push(ilike(wikiPages.title, `%${query}%`)) if (Object.prototype.hasOwnProperty.call(args, "parentId")) { if (args.parentId === null) conditions.push(isNull(wikiPages.parentId)) else conditions.push(eq(wikiPages.parentId, pageIdArg(args, "parentId"))) } const rows = await context.server.db .select({ id: wikiPages.id, parentId: wikiPages.parentId, title: wikiPages.title, isFolder: wikiPages.isFolder, sortOrder: wikiPages.sortOrder, entityType: wikiPages.entityType, entityId: wikiPages.entityId, entityUuid: wikiPages.entityUuid, createdAt: wikiPages.createdAt, updatedAt: wikiPages.updatedAt, }) .from(wikiPages) .where(and(...conditions)) .orderBy(asc(wikiPages.sortOrder), asc(wikiPages.title), wikiPages.id) .limit(limitFromArgs(args, 50) + 1) .offset(offsetFromArgs(args)) return paginatedResult(rows, args, 50) }, }, { name: "wiki.pages.get", title: "Wiki-Seite laden", description: "Lädt eine Wiki-Seite einschließlich ihres Inhalts anhand ihrer UUID.", requiredPermissions: ["wiki.read"], inputSchema: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } }, }, async handler(context, args) { return { page: await loadPage(context, pageIdArg(args)) } }, }, { name: "wiki.pages.create", title: "Wiki-Seite erstellen", description: "Erstellt eine Seite oder einen Ordner im allgemeinen Wiki oder im Wiki einer einzelnen Entität.", requiredPermissions: ["wiki.write"], inputSchema: { type: "object", required: ["title"], properties: { ...pageProperties, ...entityScopeProperties, }, }, async handler(context, args) { const title = stringArg(args, "title") if (!title) throw new Error("title ist erforderlich") const scope = prepareWikiScope(args) const parentId = args.parentId === undefined || args.parentId === null ? null : pageIdArg(args, "parentId") await assertValidParent(context, parentId, scope) const sortOrder = args.sortOrder === undefined ? 0 : Number(args.sortOrder) if (!Number.isInteger(sortOrder)) throw new Error("sortOrder muss eine ganze Zahl sein") const [page] = await context.server.db .insert(wikiPages) .values({ tenantId: context.tenantId, title, content: args.content ?? null, parentId, sortOrder, isFolder: args.isFolder === true, ...scope, createdBy: context.userId, updatedBy: context.userId, }) .returning() return { page } }, }, { name: "wiki.pages.update", title: "Wiki-Seite bearbeiten", description: "Bearbeitet Titel, Inhalt, Position oder Ordnereigenschaften einer Wiki-Seite. Funktioniert für das allgemeine Wiki und für Entitäts-Wikis.", requiredPermissions: ["wiki.write"], inputSchema: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" }, ...pageProperties, }, }, async handler(context, args) { const id = pageIdArg(args) const existing = await loadPage(context, id) const update: Record = { updatedAt: new Date(), updatedBy: context.userId, } let changed = false if (args.title !== undefined) { const title = stringArg(args, "title") if (!title) throw new Error("title darf nicht leer sein") update.title = title changed = true } if (Object.prototype.hasOwnProperty.call(args, "content")) { update.content = args.content ?? null changed = true } if (Object.prototype.hasOwnProperty.call(args, "parentId")) { const parentId = args.parentId === null ? null : pageIdArg(args, "parentId") await assertValidParent(context, parentId, scopeFromPage(existing), id) update.parentId = parentId changed = true } if (args.sortOrder !== undefined) { const sortOrder = Number(args.sortOrder) if (!Number.isInteger(sortOrder)) throw new Error("sortOrder muss eine ganze Zahl sein") update.sortOrder = sortOrder changed = true } if (args.isFolder !== undefined) { if (typeof args.isFolder !== "boolean") throw new Error("isFolder muss ein boolescher Wert sein") update.isFolder = args.isFolder changed = true } if (!changed) throw new Error("Mindestens ein zu änderndes Feld ist erforderlich") const [page] = await context.server.db .update(wikiPages) .set(update) .where(and(eq(wikiPages.id, id), eq(wikiPages.tenantId, context.tenantId))) .returning() if (!page) throw new Error("Wiki-Seite nicht gefunden") return { page } }, }, { name: "wiki.pages.delete", title: "Wiki-Seite löschen", description: "Löscht eine Wiki-Seite oder einen Wiki-Ordner samt untergeordneten Einträgen.", requiredPermissions: ["wiki.write"], inputSchema: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } }, }, async handler(context, args) { const id = pageIdArg(args) const deleted = await context.server.db .delete(wikiPages) .where(and(eq(wikiPages.id, id), eq(wikiPages.tenantId, context.tenantId))) .returning({ id: wikiPages.id }) if (!deleted[0]) throw new Error("Wiki-Seite nicht gefunden") return { deletedId: deleted[0].id } }, }, ]