From 2ad4dada92d91c1d1a9052ddf3a15fc280a7dd14 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 2 Sep 2026 09:58:42 +0000 Subject: [PATCH] =?UTF-8?q?Wiki-Bearbeitung=20im=20MCP=20erg=C3=A4nzen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/mcp/registry.ts | 2 + backend/src/mcp/tools/wiki.ts | 322 +++++++++++++++++++++++ backend/src/modules/bootstrap.service.ts | 2 + backend/tests/mcpWiki.test.ts | 79 ++++++ 4 files changed, 405 insertions(+) create mode 100644 backend/src/mcp/tools/wiki.ts create mode 100644 backend/tests/mcpWiki.test.ts diff --git a/backend/src/mcp/registry.ts b/backend/src/mcp/registry.ts index 97def75..2ecb3e6 100644 --- a/backend/src/mcp/registry.ts +++ b/backend/src/mcp/registry.ts @@ -1,11 +1,13 @@ import { accountingTools } from "./tools/accounting" import { masterdataTools } from "./tools/masterdata" import { organisationTools } from "./tools/organisation" +import { wikiTools } from "./tools/wiki" export const mcpTools = [ ...accountingTools, ...masterdataTools, ...organisationTools, + ...wikiTools, ] export const mcpToolMap = new Map(mcpTools.map((tool) => [tool.name, tool])) diff --git a/backend/src/mcp/tools/wiki.ts b/backend/src/mcp/tools/wiki.ts new file mode 100644 index 0000000..1614a15 --- /dev/null +++ b/backend/src/mcp/tools/wiki.ts @@ -0,0 +1,322 @@ +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 +} + +const limitFromArgs = (args: Record) => { + const value = Number(args.limit ?? 50) + if (!Number.isFinite(value)) return 50 + return Math.min(Math.max(Math.trunc(value), 1), 100) +} + +const offsetFromArgs = (args: Record) => { + const value = Number(args.offset ?? 0) + if (!Number.isFinite(value)) return 0 + return Math.max(Math.trunc(value), 0) +} + +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." }, + limit: { type: "integer", minimum: 1, maximum: 100, default: 50 }, + offset: { type: "integer", minimum: 0, default: 0 }, + }, + }, + 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)) + .limit(limitFromArgs(args)) + .offset(offsetFromArgs(args)) + + return { rows } + }, + }, + { + 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 } + }, + }, +] diff --git a/backend/src/modules/bootstrap.service.ts b/backend/src/modules/bootstrap.service.ts index 13c94b7..a6bd9dc 100644 --- a/backend/src/modules/bootstrap.service.ts +++ b/backend/src/modules/bootstrap.service.ts @@ -50,6 +50,8 @@ const adminPermissions = [ "organisation.events.read", "organisation.tasks.read", "organisation.tasks.write", + "wiki.read", + "wiki.write", ] const defaultTaxTypes = [ diff --git a/backend/tests/mcpWiki.test.ts b/backend/tests/mcpWiki.test.ts new file mode 100644 index 0000000..144f2cc --- /dev/null +++ b/backend/tests/mcpWiki.test.ts @@ -0,0 +1,79 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { mcpToolMap } from "../src/mcp/registry" +import { prepareWikiScope, wikiTools } from "../src/mcp/tools/wiki" + +const tool = (name: string) => wikiTools.find((candidate) => candidate.name === name) + +test("registers read and write tools for global and entity wiki pages", () => { + const expected = [ + ["wiki.pages.list", "wiki.read"], + ["wiki.pages.get", "wiki.read"], + ["wiki.pages.create", "wiki.write"], + ["wiki.pages.update", "wiki.write"], + ["wiki.pages.delete", "wiki.write"], + ] as const + + for (const [name, permission] of expected) { + assert.deepEqual(tool(name)?.requiredPermissions, [permission]) + assert.equal(mcpToolMap.get(name), tool(name)) + } +}) + +test("exposes entity scope on list and create tools", () => { + for (const name of ["wiki.pages.list", "wiki.pages.create"]) { + const properties = (tool(name)?.inputSchema as any)?.properties + assert.equal(properties.entityType.type, "string") + assert.equal(properties.entityId.type, "number") + assert.equal(properties.entityUuid.type, "string") + } +}) + +test("prepares global, numeric entity and UUID entity wiki scopes", () => { + assert.deepEqual(prepareWikiScope({}), { + entityType: null, + entityId: null, + entityUuid: null, + }) + assert.deepEqual(prepareWikiScope({ entityType: "customers", entityId: 42 }), { + entityType: "customers", + entityId: 42, + entityUuid: null, + }) + assert.deepEqual(prepareWikiScope({ + entityType: "ownaccounts", + entityUuid: "57d31d62-11a2-47c5-b074-69f9c5ab8bba", + }), { + entityType: "ownaccounts", + entityId: null, + entityUuid: "57d31d62-11a2-47c5-b074-69f9c5ab8bba", + }) +}) + +test("rejects incomplete or ambiguous entity wiki scopes", () => { + assert.throws(() => prepareWikiScope({ entityType: "customers" }), /entityId oder entityUuid/) + assert.throws(() => prepareWikiScope({ entityId: 42 }), /entityType/) + assert.throws( + () => prepareWikiScope({ + entityType: "customers", + entityId: 42, + entityUuid: "57d31d62-11a2-47c5-b074-69f9c5ab8bba", + }), + /entweder entityId oder entityUuid/, + ) + assert.throws( + () => prepareWikiScope({ entityType: "customers", entityUuid: "not-a-uuid" }), + /gültige UUID/, + ) +}) + +test("requires identifiers and editable fields on mutating tools", () => { + assert.deepEqual((tool("wiki.pages.create")?.inputSchema as any).required, ["title"]) + assert.deepEqual((tool("wiki.pages.update")?.inputSchema as any).required, ["id"]) + assert.deepEqual((tool("wiki.pages.delete")?.inputSchema as any).required, ["id"]) + + const updateProperties = (tool("wiki.pages.update")?.inputSchema as any).properties + for (const field of ["title", "content", "parentId", "sortOrder", "isFolder"]) { + assert.ok(updateProperties[field], `${field} fehlt im Update-Schema`) + } +})