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
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:
@@ -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]))
|
||||
|
||||
322
backend/src/mcp/tools/wiki.ts
Normal file
322
backend/src/mcp/tools/wiki.ts
Normal 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 }
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -50,6 +50,8 @@ const adminPermissions = [
|
||||
"organisation.events.read",
|
||||
"organisation.tasks.read",
|
||||
"organisation.tasks.write",
|
||||
"wiki.read",
|
||||
"wiki.write",
|
||||
]
|
||||
|
||||
const defaultTaxTypes = [
|
||||
|
||||
79
backend/tests/mcpWiki.test.ts
Normal file
79
backend/tests/mcpWiki.test.ts
Normal 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`)
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user