diff --git a/backend/src/mcp/registry.ts b/backend/src/mcp/registry.ts index b2defcd..97def75 100644 --- a/backend/src/mcp/registry.ts +++ b/backend/src/mcp/registry.ts @@ -1,10 +1,11 @@ import { accountingTools } from "./tools/accounting" +import { masterdataTools } from "./tools/masterdata" import { organisationTools } from "./tools/organisation" export const mcpTools = [ ...accountingTools, + ...masterdataTools, ...organisationTools, ] export const mcpToolMap = new Map(mcpTools.map((tool) => [tool.name, tool])) - diff --git a/backend/src/mcp/tools/masterdata.ts b/backend/src/mcp/tools/masterdata.ts new file mode 100644 index 0000000..ccfc792 --- /dev/null +++ b/backend/src/mcp/tools/masterdata.ts @@ -0,0 +1,571 @@ +import { and, desc, eq, ilike, or } from "drizzle-orm" +import { + branches, + contacts, + costcentres, + customers, + inventoryitems, + products, + services, + teams, + units, + vehicles, + vendors, +} from "../../../db/schema" +import { McpTool } from "../types" + +const limitFromArgs = (args: Record, fallback = 25) => { + const raw = Number(args.limit ?? fallback) + if (!Number.isFinite(raw)) return fallback + return Math.min(Math.max(Math.trunc(raw), 1), 100) +} + +const stringArg = (args: Record, key: string) => { + const value = args[key] + return typeof value === "string" && value.trim() ? value.trim() : null +} + +const numberArg = (args: Record, key: string) => { + const value = Number(args[key]) + return Number.isFinite(value) ? value : null +} + +const uuidArg = (args: Record, key: string) => { + const value = stringArg(args, key) + return value && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value) + ? value + : null +} + +export const masterdataTools: McpTool[] = [ + { + name: "masterdata.customers.get", + title: "Kunde laden", + description: "Lädt einen Kunden des aktiven Mandanten anhand seiner ID.", + requiredPermissions: ["masterdata.customers.read"], + inputSchema: { + type: "object", + required: ["id"], + properties: { id: { type: "number" } }, + }, + async handler(context, args) { + const id = numberArg(args, "id") + if (!id) throw new Error("id ist erforderlich") + + const rows = await context.server.db + .select() + .from(customers) + .where(and(eq(customers.id, id), eq(customers.tenant, context.tenantId))) + .limit(1) + + if (!rows[0]) throw new Error("Kunde nicht gefunden") + return { customer: rows[0] } + }, + }, + { + name: "masterdata.vendors.search", + title: "Lieferanten suchen", + description: "Sucht Lieferanten des aktiven Mandanten.", + requiredPermissions: ["masterdata.vendors.read"], + inputSchema: { + type: "object", + properties: { + query: { type: "string", description: "Suchtext für Name, Lieferantennummer oder Notizen." }, + includeArchived: { type: "boolean", default: false }, + limit: { type: "number", minimum: 1, maximum: 100 }, + }, + }, + async handler(context, args) { + const conditions = [eq(vendors.tenant, context.tenantId)] + const query = stringArg(args, "query") + + if (query) { + conditions.push(or( + ilike(vendors.name, `%${query}%`), + ilike(vendors.vendorNumber, `%${query}%`), + ilike(vendors.notes, `%${query}%`) + )) + } + if (args.includeArchived !== true) conditions.push(eq(vendors.archived, false)) + + const rows = await context.server.db + .select() + .from(vendors) + .where(and(...conditions)) + .orderBy(vendors.name) + .limit(limitFromArgs(args)) + + return { rows } + }, + }, + { + name: "masterdata.vendors.get", + title: "Lieferant laden", + description: "Lädt einen Lieferanten des aktiven Mandanten anhand seiner ID.", + requiredPermissions: ["masterdata.vendors.read"], + inputSchema: { + type: "object", + required: ["id"], + properties: { id: { type: "number" } }, + }, + async handler(context, args) { + const id = numberArg(args, "id") + if (!id) throw new Error("id ist erforderlich") + + const rows = await context.server.db + .select() + .from(vendors) + .where(and(eq(vendors.id, id), eq(vendors.tenant, context.tenantId))) + .limit(1) + + if (!rows[0]) throw new Error("Lieferant nicht gefunden") + return { vendor: rows[0] } + }, + }, + { + name: "masterdata.contacts.search", + title: "Kontakte suchen", + description: "Sucht Kontakte des aktiven Mandanten, optional zu Kunde oder Lieferant.", + requiredPermissions: ["masterdata.contacts.read"], + inputSchema: { + type: "object", + properties: { + query: { type: "string", description: "Suchtext für Name, E-Mail, Telefon, Rolle oder Notizen." }, + customer: { type: "number" }, + vendor: { type: "number" }, + includeArchived: { type: "boolean", default: false }, + limit: { type: "number", minimum: 1, maximum: 100 }, + }, + }, + async handler(context, args) { + const conditions = [eq(contacts.tenant, context.tenantId)] + const query = stringArg(args, "query") + const customer = numberArg(args, "customer") + const vendor = numberArg(args, "vendor") + + if (query) { + conditions.push(or( + ilike(contacts.fullName, `%${query}%`), + ilike(contacts.firstName, `%${query}%`), + ilike(contacts.lastName, `%${query}%`), + ilike(contacts.email, `%${query}%`), + ilike(contacts.phoneMobile, `%${query}%`), + ilike(contacts.phoneHome, `%${query}%`), + ilike(contacts.role, `%${query}%`), + ilike(contacts.notes, `%${query}%`) + )) + } + if (customer) conditions.push(eq(contacts.customer, customer)) + if (vendor) conditions.push(eq(contacts.vendor, vendor)) + if (args.includeArchived !== true) conditions.push(eq(contacts.archived, false)) + + const rows = await context.server.db + .select() + .from(contacts) + .where(and(...conditions)) + .orderBy(contacts.fullName) + .limit(limitFromArgs(args)) + + return { rows } + }, + }, + { + name: "masterdata.products.search", + title: "Artikel suchen", + description: "Sucht Artikel und Materialstammdaten des aktiven Mandanten.", + requiredPermissions: ["masterdata.products.read"], + inputSchema: { + type: "object", + properties: { + query: { type: "string", description: "Suchtext für Name, Artikelnummer, Hersteller, EAN, Barcode oder Beschreibung." }, + includeArchived: { type: "boolean", default: false }, + limit: { type: "number", minimum: 1, maximum: 100 }, + }, + }, + async handler(context, args) { + const conditions = [eq(products.tenant, context.tenantId)] + const query = stringArg(args, "query") + + if (query) { + conditions.push(or( + ilike(products.name, `%${query}%`), + ilike(products.article_number, `%${query}%`), + ilike(products.manufacturer, `%${query}%`), + ilike(products.manufacturer_number, `%${query}%`), + ilike(products.ean, `%${query}%`), + ilike(products.barcode, `%${query}%`), + ilike(products.description, `%${query}%`) + )) + } + if (args.includeArchived !== true) conditions.push(eq(products.archived, false)) + + const rows = await context.server.db + .select() + .from(products) + .where(and(...conditions)) + .orderBy(products.name) + .limit(limitFromArgs(args)) + + return { rows } + }, + }, + { + name: "masterdata.products.get", + title: "Artikel laden", + description: "Lädt einen Artikel des aktiven Mandanten anhand seiner ID.", + requiredPermissions: ["masterdata.products.read"], + inputSchema: { + type: "object", + required: ["id"], + properties: { id: { type: "number" } }, + }, + async handler(context, args) { + const id = numberArg(args, "id") + if (!id) throw new Error("id ist erforderlich") + + const rows = await context.server.db + .select() + .from(products) + .where(and(eq(products.id, id), eq(products.tenant, context.tenantId))) + .limit(1) + + if (!rows[0]) throw new Error("Artikel nicht gefunden") + return { product: rows[0] } + }, + }, + { + name: "masterdata.services.search", + title: "Leistungen suchen", + description: "Sucht Leistungsstammdaten des aktiven Mandanten.", + requiredPermissions: ["masterdata.services.read"], + inputSchema: { + type: "object", + properties: { + query: { type: "string", description: "Suchtext für Name, Leistungsnummer oder Beschreibung." }, + includeArchived: { type: "boolean", default: false }, + limit: { type: "number", minimum: 1, maximum: 100 }, + }, + }, + async handler(context, args) { + const conditions = [eq(services.tenant, context.tenantId)] + const query = stringArg(args, "query") + + if (query) { + conditions.push(or( + ilike(services.name, `%${query}%`), + ilike(services.description, `%${query}%`) + )) + } + if (args.includeArchived !== true) conditions.push(eq(services.archived, false)) + + const rows = await context.server.db + .select() + .from(services) + .where(and(...conditions)) + .orderBy(services.name) + .limit(limitFromArgs(args)) + + return { rows } + }, + }, + { + name: "masterdata.services.get", + title: "Leistung laden", + description: "Lädt eine Leistung des aktiven Mandanten anhand ihrer ID.", + requiredPermissions: ["masterdata.services.read"], + inputSchema: { + type: "object", + required: ["id"], + properties: { id: { type: "number" } }, + }, + async handler(context, args) { + const id = numberArg(args, "id") + if (!id) throw new Error("id ist erforderlich") + + const rows = await context.server.db + .select() + .from(services) + .where(and(eq(services.id, id), eq(services.tenant, context.tenantId))) + .limit(1) + + if (!rows[0]) throw new Error("Leistung nicht gefunden") + return { service: rows[0] } + }, + }, + { + name: "masterdata.cost_centres.list", + title: "Kostenstellen auflisten", + description: "Listet Kostenstellen des aktiven Mandanten.", + requiredPermissions: ["masterdata.cost_centres.read"], + inputSchema: { + type: "object", + properties: { + query: { type: "string", description: "Suchtext für Nummer, Name oder Beschreibung." }, + branch: { type: "number" }, + project: { type: "number" }, + includeArchived: { type: "boolean", default: false }, + limit: { type: "number", minimum: 1, maximum: 100 }, + }, + }, + async handler(context, args) { + const conditions = [eq(costcentres.tenant, context.tenantId)] + const query = stringArg(args, "query") + const branch = numberArg(args, "branch") + const project = numberArg(args, "project") + + if (query) { + conditions.push(or( + ilike(costcentres.number, `%${query}%`), + ilike(costcentres.name, `%${query}%`), + ilike(costcentres.description, `%${query}%`) + )) + } + if (branch) conditions.push(eq(costcentres.branch, branch)) + if (project) conditions.push(eq(costcentres.project, project)) + if (args.includeArchived !== true) conditions.push(eq(costcentres.archived, false)) + + const rows = await context.server.db + .select() + .from(costcentres) + .where(and(...conditions)) + .orderBy(costcentres.number) + .limit(limitFromArgs(args)) + + return { rows } + }, + }, + { + name: "masterdata.cost_centres.get", + title: "Kostenstelle laden", + description: "Lädt eine Kostenstelle des aktiven Mandanten anhand ihrer UUID.", + requiredPermissions: ["masterdata.cost_centres.read"], + inputSchema: { + type: "object", + required: ["id"], + properties: { id: { type: "string" } }, + }, + async handler(context, args) { + const id = uuidArg(args, "id") + if (!id) throw new Error("gültige id ist erforderlich") + + const rows = await context.server.db + .select() + .from(costcentres) + .where(and(eq(costcentres.id, id), eq(costcentres.tenant, context.tenantId))) + .limit(1) + + if (!rows[0]) throw new Error("Kostenstelle nicht gefunden") + return { costCentre: rows[0] } + }, + }, + { + name: "masterdata.branches.list", + title: "Niederlassungen auflisten", + description: "Listet Niederlassungen des aktiven Mandanten.", + requiredPermissions: ["masterdata.branches.read"], + inputSchema: { + type: "object", + properties: { + query: { type: "string", description: "Suchtext für Nummer, Name oder Beschreibung." }, + includeArchived: { type: "boolean", default: false }, + limit: { type: "number", minimum: 1, maximum: 100 }, + }, + }, + async handler(context, args) { + const conditions = [eq(branches.tenant, context.tenantId)] + const query = stringArg(args, "query") + + if (query) { + conditions.push(or( + ilike(branches.number, `%${query}%`), + ilike(branches.name, `%${query}%`), + ilike(branches.description, `%${query}%`) + )) + } + if (args.includeArchived !== true) conditions.push(eq(branches.archived, false)) + + const rows = await context.server.db + .select() + .from(branches) + .where(and(...conditions)) + .orderBy(branches.name) + .limit(limitFromArgs(args)) + + return { rows } + }, + }, + { + name: "masterdata.teams.list", + title: "Teams auflisten", + description: "Listet Teams des aktiven Mandanten.", + requiredPermissions: ["masterdata.teams.read"], + inputSchema: { + type: "object", + properties: { + query: { type: "string", description: "Suchtext für Name oder Beschreibung." }, + branch: { type: "number" }, + includeArchived: { type: "boolean", default: false }, + limit: { type: "number", minimum: 1, maximum: 100 }, + }, + }, + async handler(context, args) { + const conditions = [eq(teams.tenant, context.tenantId)] + const query = stringArg(args, "query") + const branch = numberArg(args, "branch") + + if (query) { + conditions.push(or( + ilike(teams.name, `%${query}%`), + ilike(teams.description, `%${query}%`) + )) + } + if (branch) conditions.push(eq(teams.branch, branch)) + if (args.includeArchived !== true) conditions.push(eq(teams.archived, false)) + + const rows = await context.server.db + .select() + .from(teams) + .where(and(...conditions)) + .orderBy(teams.name) + .limit(limitFromArgs(args)) + + return { rows } + }, + }, + { + name: "masterdata.vehicles.list", + title: "Fahrzeuge auflisten", + description: "Listet Fahrzeuge des aktiven Mandanten.", + requiredPermissions: ["masterdata.vehicles.read"], + inputSchema: { + type: "object", + properties: { + query: { type: "string", description: "Suchtext für Name, Kennzeichen, FIN oder Farbe." }, + includeArchived: { type: "boolean", default: false }, + limit: { type: "number", minimum: 1, maximum: 100 }, + }, + }, + async handler(context, args) { + const conditions = [eq(vehicles.tenant, context.tenantId)] + const query = stringArg(args, "query") + + if (query) { + conditions.push(or( + ilike(vehicles.name, `%${query}%`), + ilike(vehicles.license_plate, `%${query}%`), + ilike(vehicles.vin, `%${query}%`), + ilike(vehicles.color, `%${query}%`) + )) + } + if (args.includeArchived !== true) conditions.push(eq(vehicles.archived, false)) + + const rows = await context.server.db + .select() + .from(vehicles) + .where(and(...conditions)) + .orderBy(vehicles.name) + .limit(limitFromArgs(args)) + + return { rows } + }, + }, + { + name: "masterdata.inventory_items.search", + title: "Inventar suchen", + description: "Sucht Inventar- und Geräte-Stammdaten des aktiven Mandanten.", + requiredPermissions: ["masterdata.inventory_items.read"], + inputSchema: { + type: "object", + properties: { + query: { type: "string", description: "Suchtext für Name, Artikelnummer, Seriennummer, Hersteller oder Beschreibung." }, + vendor: { type: "number" }, + includeArchived: { type: "boolean", default: false }, + limit: { type: "number", minimum: 1, maximum: 100 }, + }, + }, + async handler(context, args) { + const conditions = [eq(inventoryitems.tenant, context.tenantId)] + const query = stringArg(args, "query") + const vendor = numberArg(args, "vendor") + + if (query) { + conditions.push(or( + ilike(inventoryitems.name, `%${query}%`), + ilike(inventoryitems.articleNumber, `%${query}%`), + ilike(inventoryitems.serialNumber, `%${query}%`), + ilike(inventoryitems.manufacturer, `%${query}%`), + ilike(inventoryitems.manufacturerNumber, `%${query}%`), + ilike(inventoryitems.description, `%${query}%`) + )) + } + if (vendor) conditions.push(eq(inventoryitems.vendor, vendor)) + if (args.includeArchived !== true) conditions.push(eq(inventoryitems.archived, false)) + + const rows = await context.server.db + .select() + .from(inventoryitems) + .where(and(...conditions)) + .orderBy(inventoryitems.name) + .limit(limitFromArgs(args)) + + return { rows } + }, + }, + { + name: "masterdata.inventory_items.get", + title: "Inventar laden", + description: "Lädt einen Inventar- oder Geräte-Stammdatensatz anhand seiner ID.", + requiredPermissions: ["masterdata.inventory_items.read"], + inputSchema: { + type: "object", + required: ["id"], + properties: { id: { type: "number" } }, + }, + async handler(context, args) { + const id = numberArg(args, "id") + if (!id) throw new Error("id ist erforderlich") + + const rows = await context.server.db + .select() + .from(inventoryitems) + .where(and(eq(inventoryitems.id, id), eq(inventoryitems.tenant, context.tenantId))) + .limit(1) + + if (!rows[0]) throw new Error("Inventar nicht gefunden") + return { inventoryItem: rows[0] } + }, + }, + { + name: "masterdata.units.list", + title: "Einheiten auflisten", + description: "Listet globale Mengeneinheiten.", + requiredPermissions: ["masterdata.units.read"], + inputSchema: { + type: "object", + properties: { + query: { type: "string", description: "Suchtext für Name, Singular, Plural oder Kürzel." }, + limit: { type: "number", minimum: 1, maximum: 100 }, + }, + }, + async handler(context, args) { + const query = stringArg(args, "query") + + const rows = await context.server.db + .select() + .from(units) + .where(query + ? or( + ilike(units.name, `%${query}%`), + ilike(units.single, `%${query}%`), + ilike(units.multiple, `%${query}%`), + ilike(units.short, `%${query}%`) + ) + : undefined) + .orderBy(units.name) + .limit(limitFromArgs(args)) + + return { rows } + }, + }, +] +