314 lines
13 KiB
TypeScript
314 lines
13 KiB
TypeScript
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<string, unknown>, key: string) => {
|
|
const value = args[key]
|
|
return typeof value === "string" && value.trim() ? value.trim() : null
|
|
}
|
|
|
|
const uuidArg = (args: Record<string, unknown>, key: string) => {
|
|
const value = stringArg(args, key)
|
|
if (!value || !UUID_PATTERN.test(value)) return null
|
|
return value
|
|
}
|
|
|
|
const pageIdArg = (args: Record<string, unknown>, 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<string, unknown>): 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<string, unknown> = {
|
|
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 }
|
|
},
|
|
},
|
|
]
|