Stammdaten-Tools für FEDEO MCP ergänzen

Ergänzt eine eigene MCP-Toolgruppe für Stammdaten. Enthalten sind read-only Werkzeuge für Kunden, Lieferanten, Kontakte, Artikel, Leistungen, Kostenstellen, Niederlassungen, Teams, Fahrzeuge, Inventar und Einheiten.
This commit is contained in:
2026-05-11 13:12:01 +02:00
parent a185c6eb11
commit f01881a6ce
2 changed files with 573 additions and 1 deletions

View File

@@ -1,10 +1,11 @@
import { accountingTools } from "./tools/accounting" import { accountingTools } from "./tools/accounting"
import { masterdataTools } from "./tools/masterdata"
import { organisationTools } from "./tools/organisation" import { organisationTools } from "./tools/organisation"
export const mcpTools = [ export const mcpTools = [
...accountingTools, ...accountingTools,
...masterdataTools,
...organisationTools, ...organisationTools,
] ]
export const mcpToolMap = new Map(mcpTools.map((tool) => [tool.name, tool])) export const mcpToolMap = new Map(mcpTools.map((tool) => [tool.name, tool]))

View File

@@ -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<string, unknown>, 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<string, unknown>, key: string) => {
const value = args[key]
return typeof value === "string" && value.trim() ? value.trim() : null
}
const numberArg = (args: Record<string, unknown>, key: string) => {
const value = Number(args[key])
return Number.isFinite(value) ? value : null
}
const uuidArg = (args: Record<string, unknown>, 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 }
},
},
]