Wiki-Bearbeitung im MCP ergänzen
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 46s
Build and Push Docker Images / build-frontend (push) Successful in 23s
Build and Push Docker Images / build-website (push) Successful in 22s
Build and Push Docker Images / build-central-services-api (push) Successful in 22s
Build and Push Docker Images / build-docs (push) Successful in 23s
Build and Push Docker Images / build-central-services-admin (push) Successful in 22s

This commit is contained in:
root
2026-09-02 09:58:42 +00:00
parent 21fb8c6649
commit 2ad4dada92
4 changed files with 405 additions and 0 deletions

View File

@@ -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]))

View File

@@ -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<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
}
const limitFromArgs = (args: Record<string, unknown>) => {
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<string, unknown>) => {
const value = Number(args.offset ?? 0)
if (!Number.isFinite(value)) return 0
return Math.max(Math.trunc(value), 0)
}
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." },
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<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 }
},
},
]

View File

@@ -50,6 +50,8 @@ const adminPermissions = [
"organisation.events.read",
"organisation.tasks.read",
"organisation.tasks.write",
"wiki.read",
"wiki.write",
]
const defaultTaxTypes = [

View File

@@ -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`)
}
})