From 342ad9764e6f1d8c7798db748129ddf2d47bc674 Mon Sep 17 00:00:00 2001 From: flfeders Date: Mon, 14 Sep 2026 13:17:37 +0200 Subject: [PATCH] feat(mcp): paginate record lists and describe document inputs --- backend/src/mcp/documentSchemas.ts | 62 +++++++++++++++++ backend/src/mcp/pagination.ts | 31 +++++++++ backend/src/mcp/tools/accounting.ts | 82 ++++++++++++----------- backend/src/mcp/tools/masterdata.ts | 96 ++++++++++++++------------- backend/src/mcp/tools/organisation.ts | 51 +++++++------- backend/src/mcp/tools/wiki.ts | 21 ++---- backend/tests/mcpPagination.test.ts | 63 ++++++++++++++++++ docs/technik/mcp.md | 19 ++++++ 8 files changed, 302 insertions(+), 123 deletions(-) create mode 100644 backend/src/mcp/documentSchemas.ts create mode 100644 backend/src/mcp/pagination.ts create mode 100644 backend/tests/mcpPagination.test.ts create mode 100644 docs/technik/mcp.md diff --git a/backend/src/mcp/documentSchemas.ts b/backend/src/mcp/documentSchemas.ts new file mode 100644 index 00000000..4a3fb09d --- /dev/null +++ b/backend/src/mcp/documentSchemas.ts @@ -0,0 +1,62 @@ +const nullableText = { type: ["string", "null"] } + +export const outgoingAddressSchema = { + type: "object", + description: "Abweichende Empfängeradresse; Feldnamen entsprechen dem Belegeditor.", + properties: { street: nullableText, special: nullableText, zip: nullableText, city: nullableText }, + additionalProperties: true, +} + +export const outgoingRowsSchema = { + type: "array", + description: "Belegpositionen als Objekte. Beträge numerisch in EUR, unit als Einheiten-ID. Mengen und Preise dürfen für Korrekturpositionen negativ sein.", + items: { + type: "object", + properties: { + id: { type: "string", format: "uuid", description: "Optionale Positions-ID; wird sonst erzeugt." }, + pos: { type: "string", description: "Positionsnummer, z. B. 1.1; wird sonst erzeugt." }, + mode: { type: "string", enum: ["free", "normal", "service", "title", "text", "pagebreak"], default: "free" }, + text: { type: "string" }, + descriptionText: { type: "string" }, + product: { type: "integer", minimum: 1, description: "Artikel-ID bei mode=normal." }, + service: { type: "integer", minimum: 1, description: "Leistungs-ID bei mode=service." }, + quantity: { type: "number", default: 1 }, + unit: { type: "integer", minimum: 1 }, + price: { type: "number", description: "Netto-Einzelpreis vor Positionsrabatt." }, + inputPrice: { type: "number", description: "Eingegebener Einzelpreis; wird ohne price auch als price verwendet." }, + purchasePrice: { type: "number" }, + taxPercent: { type: "number", minimum: 0, maximum: 100 }, + discountPercent: { type: "number", minimum: 0, maximum: 100, default: 0 }, + costCentre: { type: ["string", "null"], format: "uuid", description: "Positionskostenstelle (abweichende Schreibweise zum Belegfeld costcentre)." }, + optional: { type: "boolean" }, + alternative: { type: "boolean" }, + titleLevel: { type: "integer", minimum: 1, maximum: 3 }, + linkedEntitys: { + type: "array", + items: { type: "object", properties: { type: { type: "string" }, subtype: { type: "string" }, id: { type: "integer", minimum: 1 } } }, + }, + }, + // Preserve module-specific position fields used by existing callers. + additionalProperties: true, + }, +} + +export const outgoingReportSchema = { + type: "object", + description: "Optionale, vom Aufrufer berechnete Kalkulationswerte. Diese Felder werden gespeichert, nicht durch MCP berechnet.", + properties: { + totalProductsPurchasePrice: { type: "number" }, + totalProductsFromServicesPurchasePrice: { type: "number" }, + totalMargin: { type: "number" }, + totalHoursSellingPrice: { type: "number" }, + totalHoursFromServices: { + type: "object", + properties: { + total: { type: "number" }, + totalPurchasePrice: { type: "number" }, + byName: { type: "object", additionalProperties: { type: "number" } }, + }, + }, + }, + additionalProperties: true, +} diff --git a/backend/src/mcp/pagination.ts b/backend/src/mcp/pagination.ts new file mode 100644 index 00000000..073ee4f9 --- /dev/null +++ b/backend/src/mcp/pagination.ts @@ -0,0 +1,31 @@ +/** Offset pagination for a stable dataset; fetch one extra row to detect the next page. */ +export const paginationProperties = { + limit: { type: "integer", minimum: 1, maximum: 100, default: 25 }, + offset: { type: "integer", minimum: 0, default: 0, description: "Startposition. Für Folgeseiten pagination.nextOffset mit denselben Filtern verwenden." }, +} + +export function limitFromArgs(args: Record, fallback = 25) { + const value = args.limit ?? fallback + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 || value > 100) { + throw new Error("limit muss eine ganze Zahl zwischen 1 und 100 sein") + } + return value +} + +export function offsetFromArgs(args: Record) { + const value = args.offset ?? 0 + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { + throw new Error("offset muss eine nicht negative ganze Zahl sein") + } + return value +} + +export function paginatedResult(rows: T[], args: Record, fallback = 25) { + const limit = limitFromArgs(args, fallback) + const offset = offsetFromArgs(args) + const hasMore = rows.length > limit + return { + rows: rows.slice(0, limit), + pagination: { limit, offset, hasMore, nextOffset: hasMore ? offset + limit : null }, + } +} diff --git a/backend/src/mcp/tools/accounting.ts b/backend/src/mcp/tools/accounting.ts index 272507a4..310aa7ae 100644 --- a/backend/src/mcp/tools/accounting.ts +++ b/backend/src/mcp/tools/accounting.ts @@ -1,3 +1,5 @@ +import { outgoingAddressSchema, outgoingRowsSchema, outgoingReportSchema } from "../documentSchemas" +import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination" import { and, desc, eq, ilike, or } from "drizzle-orm" import { randomUUID } from "node:crypto" import { @@ -27,11 +29,6 @@ import { McpTool } from "../types" export { prepareStatementAllocationInput } from "../statementAllocations" -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] @@ -456,7 +453,7 @@ export const accountingTools: McpTool[] = [ state: { type: "string", description: "Optionaler Statusfilter, z. B. Entwurf oder Gebucht." }, customer: { type: "number" }, project: { type: "number" }, - limit: { type: "number", minimum: 1, maximum: 100 }, + ...paginationProperties, }, }, async handler(context, args) { @@ -479,10 +476,11 @@ export const accountingTools: McpTool[] = [ .select() .from(createddocuments) .where(and(...conditions)) - .orderBy(desc(createddocuments.createdAt)) - .limit(limitFromArgs(args)) + .orderBy(desc(createddocuments.createdAt), createddocuments.id) + .limit(limitFromArgs(args) + 1) + .offset(offsetFromArgs(args)) - return { rows } + return paginatedResult(rows, args) }, }, { @@ -542,12 +540,12 @@ export const accountingTools: McpTool[] = [ description: { type: "string" }, startText: { type: "string" }, endText: { type: "string" }, - address: { type: "object" }, - rows: { type: "array" }, + address: outgoingAddressSchema, + rows: outgoingRowsSchema, letterhead: { type: "number" }, availableInPortal: { type: "boolean" }, customSurchargePercentage: { type: "number" }, - report: { type: "object" }, + report: outgoingReportSchema, serialConfig: serialConfigSchema, }, }, @@ -598,12 +596,12 @@ export const accountingTools: McpTool[] = [ description: { type: "string" }, startText: { type: "string" }, endText: { type: "string" }, - address: { type: "object" }, - rows: { type: "array" }, + address: outgoingAddressSchema, + rows: outgoingRowsSchema, letterhead: { type: "number" }, availableInPortal: { type: "boolean" }, customSurchargePercentage: { type: "number" }, - report: { type: "object" }, + report: outgoingReportSchema, serialConfig: serialConfigSchema, }, }, @@ -720,8 +718,8 @@ export const accountingTools: McpTool[] = [ enum: ["Standard", "13b UStG", "19 UStG", "12.3 UStG"], description: "Steuertyp des gesamten Ausgangsdokuments, nicht der einzelne USt-Satz einer Position.", }, - rows: { type: "array" }, - report: { type: "object" }, + rows: outgoingRowsSchema, + report: outgoingReportSchema, availableInPortal: { type: "boolean" }, }, }, @@ -803,7 +801,7 @@ export const accountingTools: McpTool[] = [ type: "object", properties: { status: { type: "string", enum: ["draft", "completed", "error"] }, - limit: { type: "number", minimum: 1, maximum: 100 }, + ...paginationProperties, }, }, async handler(context, args) { @@ -815,10 +813,11 @@ export const accountingTools: McpTool[] = [ .select() .from(serialExecutions) .where(and(...conditions)) - .orderBy(desc(serialExecutions.createdAt)) - .limit(limitFromArgs(args)) + .orderBy(desc(serialExecutions.createdAt), serialExecutions.id) + .limit(limitFromArgs(args) + 1) + .offset(offsetFromArgs(args)) - return { rows } + return paginatedResult(rows, args) }, }, { @@ -878,7 +877,7 @@ export const accountingTools: McpTool[] = [ type: "object", properties: { query: { type: "string", description: "Suchtext für Kontonummer, Bezeichnung oder Beschreibung." }, - limit: { type: "number", minimum: 1, maximum: 100 }, + ...paginationProperties, }, }, async handler(context, args) { @@ -908,10 +907,11 @@ export const accountingTools: McpTool[] = [ .select() .from(accounts) .where(whereCond) - .orderBy(accounts.number) - .limit(limit) + .orderBy(accounts.number, accounts.id) + .limit(limit + 1) + .offset(offsetFromArgs(args)) - return { accountChart, rows } + return { accountChart, ...paginatedResult(rows, args) } }, }, { @@ -924,7 +924,7 @@ export const accountingTools: McpTool[] = [ properties: { state: { type: "string", description: "Optionaler Statusfilter." }, paid: { type: "boolean", description: "Optionaler Zahlungsstatus." }, - limit: { type: "number", minimum: 1, maximum: 100 }, + ...paginationProperties, }, }, async handler(context, args) { @@ -939,10 +939,11 @@ export const accountingTools: McpTool[] = [ .select() .from(incominginvoices) .where(and(...conditions)) - .orderBy(desc(incominginvoices.createdAt)) - .limit(limitFromArgs(args)) + .orderBy(desc(incominginvoices.createdAt), incominginvoices.id) + .limit(limitFromArgs(args) + 1) + .offset(offsetFromArgs(args)) - return { rows } + return paginatedResult(rows, args) }, }, { @@ -981,6 +982,7 @@ export const accountingTools: McpTool[] = [ required: ["id"], properties: { id: { type: "number" }, + ...paginationProperties, }, }, async handler(context, args) { @@ -995,9 +997,11 @@ export const accountingTools: McpTool[] = [ eq(files.tenant, context.tenantId), eq(files.archived, false) )) - .orderBy(desc(files.createdAt)) + .orderBy(desc(files.createdAt), files.id) + .limit(limitFromArgs(args) + 1) + .offset(offsetFromArgs(args)) - return { rows } + return paginatedResult(rows, args) }, }, { @@ -1311,7 +1315,7 @@ export const accountingTools: McpTool[] = [ type: "object", properties: { account: { type: "number", description: "Optionale Bankkonto-ID." }, - limit: { type: "number", minimum: 1, maximum: 100 }, + ...paginationProperties, }, }, async handler(context, args) { @@ -1326,9 +1330,10 @@ export const accountingTools: McpTool[] = [ .from(bankstatements) .where(and(...conditions)) .orderBy(desc(bankstatements.date), desc(bankstatements.id)) - .limit(limitFromArgs(args)) + .limit(limitFromArgs(args) + 1) + .offset(offsetFromArgs(args)) - return { rows } + return paginatedResult(rows, args) }, }, { @@ -1367,7 +1372,7 @@ export const accountingTools: McpTool[] = [ properties: { bankstatement: { type: "number" }, incominginvoice: { type: "number" }, - limit: { type: "number", minimum: 1, maximum: 100 }, + ...paginationProperties, }, }, async handler(context, args) { @@ -1383,10 +1388,11 @@ export const accountingTools: McpTool[] = [ .select() .from(statementallocations) .where(and(...conditions)) - .orderBy(desc(statementallocations.created_at)) - .limit(limitFromArgs(args)) + .orderBy(desc(statementallocations.created_at), statementallocations.id) + .limit(limitFromArgs(args) + 1) + .offset(offsetFromArgs(args)) - return { rows } + return paginatedResult(rows, args) }, }, { diff --git a/backend/src/mcp/tools/masterdata.ts b/backend/src/mcp/tools/masterdata.ts index fef5c7e9..90e73153 100644 --- a/backend/src/mcp/tools/masterdata.ts +++ b/backend/src/mcp/tools/masterdata.ts @@ -1,3 +1,4 @@ +import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination" import { and, desc, eq, ilike, or } from "drizzle-orm" import { branches, @@ -14,11 +15,6 @@ import { } 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] @@ -71,7 +67,7 @@ export const masterdataTools: McpTool[] = [ type: "object", properties: { query: { type: "string", description: "Suchtext für Name, Lieferantennummer oder Notizen." }, - limit: { type: "number", minimum: 1, maximum: 100 }, + ...paginationProperties, }, }, async handler(context, args) { @@ -91,10 +87,11 @@ export const masterdataTools: McpTool[] = [ .select() .from(vendors) .where(and(...conditions)) - .orderBy(vendors.name) - .limit(limitFromArgs(args)) + .orderBy(vendors.name, vendors.id) + .limit(limitFromArgs(args) + 1) + .offset(offsetFromArgs(args)) - return { rows } + return paginatedResult(rows, args) }, }, { @@ -132,7 +129,7 @@ export const masterdataTools: McpTool[] = [ query: { type: "string", description: "Suchtext für Name, E-Mail, Telefon, Rolle oder Notizen." }, customer: { type: "number" }, vendor: { type: "number" }, - limit: { type: "number", minimum: 1, maximum: 100 }, + ...paginationProperties, }, }, async handler(context, args) { @@ -161,10 +158,11 @@ export const masterdataTools: McpTool[] = [ .select() .from(contacts) .where(and(...conditions)) - .orderBy(contacts.fullName) - .limit(limitFromArgs(args)) + .orderBy(contacts.fullName, contacts.id) + .limit(limitFromArgs(args) + 1) + .offset(offsetFromArgs(args)) - return { rows } + return paginatedResult(rows, args) }, }, { @@ -176,7 +174,7 @@ export const masterdataTools: McpTool[] = [ type: "object", properties: { query: { type: "string", description: "Suchtext für Name, Artikelnummer, Hersteller, EAN, Barcode oder Beschreibung." }, - limit: { type: "number", minimum: 1, maximum: 100 }, + ...paginationProperties, }, }, async handler(context, args) { @@ -200,10 +198,11 @@ export const masterdataTools: McpTool[] = [ .select() .from(products) .where(and(...conditions)) - .orderBy(products.name) - .limit(limitFromArgs(args)) + .orderBy(products.name, products.id) + .limit(limitFromArgs(args) + 1) + .offset(offsetFromArgs(args)) - return { rows } + return paginatedResult(rows, args) }, }, { @@ -239,7 +238,7 @@ export const masterdataTools: McpTool[] = [ type: "object", properties: { query: { type: "string", description: "Suchtext für Name, Leistungsnummer oder Beschreibung." }, - limit: { type: "number", minimum: 1, maximum: 100 }, + ...paginationProperties, }, }, async handler(context, args) { @@ -258,10 +257,11 @@ export const masterdataTools: McpTool[] = [ .select() .from(services) .where(and(...conditions)) - .orderBy(services.name) - .limit(limitFromArgs(args)) + .orderBy(services.name, services.id) + .limit(limitFromArgs(args) + 1) + .offset(offsetFromArgs(args)) - return { rows } + return paginatedResult(rows, args) }, }, { @@ -299,7 +299,7 @@ export const masterdataTools: McpTool[] = [ query: { type: "string", description: "Suchtext für Nummer, Name oder Beschreibung." }, branch: { type: "number" }, project: { type: "number" }, - limit: { type: "number", minimum: 1, maximum: 100 }, + ...paginationProperties, }, }, async handler(context, args) { @@ -323,10 +323,11 @@ export const masterdataTools: McpTool[] = [ .select() .from(costcentres) .where(and(...conditions)) - .orderBy(costcentres.number) - .limit(limitFromArgs(args)) + .orderBy(costcentres.number, costcentres.id) + .limit(limitFromArgs(args) + 1) + .offset(offsetFromArgs(args)) - return { rows } + return paginatedResult(rows, args) }, }, { @@ -362,7 +363,7 @@ export const masterdataTools: McpTool[] = [ type: "object", properties: { query: { type: "string", description: "Suchtext für Nummer, Name oder Beschreibung." }, - limit: { type: "number", minimum: 1, maximum: 100 }, + ...paginationProperties, }, }, async handler(context, args) { @@ -382,10 +383,11 @@ export const masterdataTools: McpTool[] = [ .select() .from(branches) .where(and(...conditions)) - .orderBy(branches.name) - .limit(limitFromArgs(args)) + .orderBy(branches.name, branches.id) + .limit(limitFromArgs(args) + 1) + .offset(offsetFromArgs(args)) - return { rows } + return paginatedResult(rows, args) }, }, { @@ -398,7 +400,7 @@ export const masterdataTools: McpTool[] = [ properties: { query: { type: "string", description: "Suchtext für Name oder Beschreibung." }, branch: { type: "number" }, - limit: { type: "number", minimum: 1, maximum: 100 }, + ...paginationProperties, }, }, async handler(context, args) { @@ -419,10 +421,11 @@ export const masterdataTools: McpTool[] = [ .select() .from(teams) .where(and(...conditions)) - .orderBy(teams.name) - .limit(limitFromArgs(args)) + .orderBy(teams.name, teams.id) + .limit(limitFromArgs(args) + 1) + .offset(offsetFromArgs(args)) - return { rows } + return paginatedResult(rows, args) }, }, { @@ -434,7 +437,7 @@ export const masterdataTools: McpTool[] = [ type: "object", properties: { query: { type: "string", description: "Suchtext für Name, Kennzeichen, FIN oder Farbe." }, - limit: { type: "number", minimum: 1, maximum: 100 }, + ...paginationProperties, }, }, async handler(context, args) { @@ -455,10 +458,11 @@ export const masterdataTools: McpTool[] = [ .select() .from(vehicles) .where(and(...conditions)) - .orderBy(vehicles.name) - .limit(limitFromArgs(args)) + .orderBy(vehicles.name, vehicles.id) + .limit(limitFromArgs(args) + 1) + .offset(offsetFromArgs(args)) - return { rows } + return paginatedResult(rows, args) }, }, { @@ -471,7 +475,7 @@ export const masterdataTools: McpTool[] = [ properties: { query: { type: "string", description: "Suchtext für Name, Artikelnummer, Seriennummer, Hersteller oder Beschreibung." }, vendor: { type: "number" }, - limit: { type: "number", minimum: 1, maximum: 100 }, + ...paginationProperties, }, }, async handler(context, args) { @@ -496,10 +500,11 @@ export const masterdataTools: McpTool[] = [ .select() .from(inventoryitems) .where(and(...conditions)) - .orderBy(inventoryitems.name) - .limit(limitFromArgs(args)) + .orderBy(inventoryitems.name, inventoryitems.id) + .limit(limitFromArgs(args) + 1) + .offset(offsetFromArgs(args)) - return { rows } + return paginatedResult(rows, args) }, }, { @@ -535,7 +540,7 @@ export const masterdataTools: McpTool[] = [ type: "object", properties: { query: { type: "string", description: "Suchtext für Name, Singular, Plural oder Kürzel." }, - limit: { type: "number", minimum: 1, maximum: 100 }, + ...paginationProperties, }, }, async handler(context, args) { @@ -552,10 +557,11 @@ export const masterdataTools: McpTool[] = [ ilike(units.short, `%${query}%`) ) : undefined) - .orderBy(units.name) - .limit(limitFromArgs(args)) + .orderBy(units.name, units.id) + .limit(limitFromArgs(args) + 1) + .offset(offsetFromArgs(args)) - return { rows } + return paginatedResult(rows, args) }, }, ] diff --git a/backend/src/mcp/tools/organisation.ts b/backend/src/mcp/tools/organisation.ts index 61d498d2..fed6865b 100644 --- a/backend/src/mcp/tools/organisation.ts +++ b/backend/src/mcp/tools/organisation.ts @@ -1,14 +1,10 @@ +import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination" import { and, desc, eq, ilike, or } from "drizzle-orm" import { customers, events, plants, projects, tasks } from "../../../db/schema" import { insertHistoryItem } from "../../utils/history" import { activateProjectPhase } from "../projectPhases" 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] @@ -30,7 +26,7 @@ export const organisationTools: McpTool[] = [ type: "object", properties: { query: { type: "string", description: "Suchtext für Name, Kundennummer, Vorname, Nachname oder Notizen." }, - limit: { type: "number", minimum: 1, maximum: 100 }, + ...paginationProperties, }, }, async handler(context, args) { @@ -62,10 +58,11 @@ export const organisationTools: McpTool[] = [ }) .from(customers) .where(and(...conditions)) - .orderBy(customers.name) - .limit(limitFromArgs(args)) + .orderBy(customers.name, customers.id) + .limit(limitFromArgs(args) + 1) + .offset(offsetFromArgs(args)) - return { rows } + return paginatedResult(rows, args) }, }, { @@ -79,7 +76,7 @@ export const organisationTools: McpTool[] = [ query: { type: "string", description: "Suchtext für Name, Projektnummer, Kundenreferenz oder Notizen." }, customer: { type: "number" }, activePhase: { type: "string" }, - limit: { type: "number", minimum: 1, maximum: 100 }, + ...paginationProperties, }, }, async handler(context, args) { @@ -104,10 +101,11 @@ export const organisationTools: McpTool[] = [ .select() .from(projects) .where(and(...conditions)) - .orderBy(desc(projects.createdAt)) - .limit(limitFromArgs(args)) + .orderBy(desc(projects.createdAt), projects.id) + .limit(limitFromArgs(args) + 1) + .offset(offsetFromArgs(args)) - return { rows } + return paginatedResult(rows, args) }, }, { @@ -217,7 +215,7 @@ export const organisationTools: McpTool[] = [ properties: { query: { type: "string", description: "Suchtext für Name." }, customer: { type: "number" }, - limit: { type: "number", minimum: 1, maximum: 100 }, + ...paginationProperties, }, }, async handler(context, args) { @@ -233,10 +231,11 @@ export const organisationTools: McpTool[] = [ .select() .from(plants) .where(and(...conditions)) - .orderBy(plants.name) - .limit(limitFromArgs(args)) + .orderBy(plants.name, plants.id) + .limit(limitFromArgs(args) + 1) + .offset(offsetFromArgs(args)) - return { rows } + return paginatedResult(rows, args) }, }, { @@ -251,7 +250,7 @@ export const organisationTools: McpTool[] = [ project: { type: "number" }, customer: { type: "number" }, eventtype: { type: "string" }, - limit: { type: "number", minimum: 1, maximum: 100 }, + ...paginationProperties, }, }, async handler(context, args) { @@ -277,10 +276,11 @@ export const organisationTools: McpTool[] = [ .select() .from(events) .where(and(...conditions)) - .orderBy(desc(events.startDate)) - .limit(limitFromArgs(args)) + .orderBy(desc(events.startDate), events.id) + .limit(limitFromArgs(args) + 1) + .offset(offsetFromArgs(args)) - return { rows } + return paginatedResult(rows, args) }, }, { @@ -294,7 +294,7 @@ export const organisationTools: McpTool[] = [ query: { type: "string", description: "Suchtext für Name, Beschreibung oder Kategorie." }, project: { type: "number" }, customer: { type: "number" }, - limit: { type: "number", minimum: 1, maximum: 100 }, + ...paginationProperties, }, }, async handler(context, args) { @@ -318,10 +318,11 @@ export const organisationTools: McpTool[] = [ .select() .from(tasks) .where(and(...conditions)) - .orderBy(desc(tasks.createdAt)) - .limit(limitFromArgs(args)) + .orderBy(desc(tasks.createdAt), tasks.id) + .limit(limitFromArgs(args) + 1) + .offset(offsetFromArgs(args)) - return { rows } + return paginatedResult(rows, args) }, }, { diff --git a/backend/src/mcp/tools/wiki.ts b/backend/src/mcp/tools/wiki.ts index 1614a153..73ad8f59 100644 --- a/backend/src/mcp/tools/wiki.ts +++ b/backend/src/mcp/tools/wiki.ts @@ -1,3 +1,4 @@ +import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination" import { and, asc, eq, ilike, isNull } from "drizzle-orm" import { wikiPages } from "../../../db/schema" import { McpContext, McpTool } from "../types" @@ -27,17 +28,7 @@ const pageIdArg = (args: Record, key = "id") => { 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") @@ -144,8 +135,8 @@ export const wikiTools: McpTool[] = [ ...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 }, + ...paginationProperties, + limit: { ...paginationProperties.limit, default: 50 }, }, }, async handler(context, args) { @@ -173,11 +164,11 @@ export const wikiTools: McpTool[] = [ }) .from(wikiPages) .where(and(...conditions)) - .orderBy(asc(wikiPages.sortOrder), asc(wikiPages.title)) - .limit(limitFromArgs(args)) + .orderBy(asc(wikiPages.sortOrder), asc(wikiPages.title), wikiPages.id) + .limit(limitFromArgs(args, 50) + 1) .offset(offsetFromArgs(args)) - return { rows } + return paginatedResult(rows, args, 50) }, }, { diff --git a/backend/tests/mcpPagination.test.ts b/backend/tests/mcpPagination.test.ts new file mode 100644 index 00000000..16008d0a --- /dev/null +++ b/backend/tests/mcpPagination.test.ts @@ -0,0 +1,63 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { getTableName } from "drizzle-orm" +import { drizzle } from "drizzle-orm/pg-proxy" +import * as schema from "../db/schema" +import { mcpTools } from "../src/mcp/registry" +import { limitFromArgs, offsetFromArgs, paginatedResult } from "../src/mcp/pagination" + +const tables = Object.values(schema).filter((value: any) => { + try { return typeof getTableName(value) === "string" } catch { return false } +}) + +for (const tool of mcpTools.filter((tool) => (tool.inputSchema.properties as any)?.offset)) { + test(`${tool.name}: retrieves 205 records across stable, bounded pages`, async () => { + const db = drizzle(async (sql, params) => { + if (sql.includes('from "tenants"')) return { rows: [["skr03"]] } + const tableName = sql.match(/from "(\w+)"/)?.[1] + const table = tables.find((table: any) => getTableName(table) === tableName) as any + assert.ok(table, sql) + assert.match(sql, /order by .*"id"/) + if (table.tenant || table.tenantId) { + assert.match(sql, /where .*"tenant(?:_id)?" = \$\d+/) + assert.ok(params.includes(7), "active tenant must be bound") + } + if (table.archived) assert.match(sql, /"archived" = \$\d+/) + const limitParam = sql.match(/limit \$(\d+)/) + const offsetParam = sql.match(/offset \$(\d+)/) + assert.ok(limitParam, "query must be bounded") + const limit = Number(params[Number(limitParam[1]) - 1]) + const offset = offsetParam ? Number(params[Number(offsetParam[1]) - 1]) : 0 + assert.equal(limit, 101, "fetch one extra row for hasMore") + const selected = sql.slice(7, sql.indexOf(" from ")).split(", ") + const idIndex = selected.findIndex((column) => /"id"$/.test(column)) + assert.ok(idIndex >= 0) + return { rows: Array.from({ length: Math.min(limit, Math.max(0, 205 - offset)) }, (_, i) => { + const values = selected.map(() => null as unknown) + values[idIndex] = offset + i + 1 + return values + }) } + }, { schema }) + const context = { server: { db }, tenantId: 7, userId: "test" } as any + const ids: unknown[] = [] + let offset = 0 + for (let page = 0; page < 3; page++) { + const result: any = await tool.handler(context, { id: 1, limit: 100, offset }) + assert.equal(result.rows.length, page < 2 ? 100 : 5) + assert.equal(result.pagination.hasMore, page < 2) + assert.equal(result.pagination.nextOffset, page < 2 ? offset + 100 : null) + ids.push(...result.rows.map((row: any) => Number(row.id))) + offset = result.pagination.nextOffset + } + assert.deepEqual(ids, Array.from({ length: 205 }, (_, i) => i + 1)) + }) +} + +test("pagination validates input and handles empty and exactly full final pages", () => { + for (const limit of [-1, 0, 101, 1.5, "10", Infinity]) assert.throws(() => limitFromArgs({ limit })) + for (const offset of [-1, 1.5, "10", Infinity]) assert.throws(() => offsetFromArgs({ offset })) + assert.equal(limitFromArgs({}), 25) + assert.equal(limitFromArgs({}, 50), 50) + assert.equal(paginatedResult([1, 2], { limit: 2 }).pagination.nextOffset, null) + assert.deepEqual(paginatedResult([], { offset: 300 }).pagination, { limit: 25, offset: 300, hasMore: false, nextOffset: null }) +}) diff --git a/docs/technik/mcp.md b/docs/technik/mcp.md new file mode 100644 index 00000000..9198c55b --- /dev/null +++ b/docs/technik/mcp.md @@ -0,0 +1,19 @@ +# FEDEO MCP + +## Listen und Pagination + +Alle datensatzbasierten MCP-Listen und Suchwerkzeuge unterstützen `limit` (ganze Zahl 1–100, Standard 25) und `offset` (ganze Zahl ab 0). Das Wiki behält Standard 50. Die statische Liste der Beleg-Steuertypen benötigt keine Pagination. + +Die Antwort behält `rows` und ergänzt: + +```json +{"pagination":{"limit":25,"offset":0,"hasMore":true,"nextOffset":25}} +``` + +Für die nächste Seite dieselben Filter und `limit` mit `offset = pagination.nextOffset` verwenden. Bei `hasMore = false` ist `nextOffset = null`. Die Sortierung enthält eine eindeutige ID als Tie-Breaker. Gleichzeitiges Anlegen, Archivieren oder Ändern sortierrelevanter Felder kann wie bei jeder Offset-Pagination Seiten verschieben; die Abfragen bilden keinen gemeinsamen Datenbanksnapshot. + +Auch `accounting.incoming_invoices.files.list` ist paginiert. Archivierte Einträge bleiben entsprechend der bisherigen MCP-Regel ausgeblendet. + +## Ausgangsbeleg-Schemas + +`tools/list` beschreibt die Unterfelder von `rows`, `address` und `report` für Beleg-Erstellung und -Bearbeitung sowie Positionen und Report beim Finalisieren. Positionen verwenden `costCentre`, der Beleg selbst `costcentre`. `unit` ist die numerische Einheiten-ID. Preise werden numerisch übergeben. Reportwerte liefert der Aufrufer; MCP berechnet sie nicht. Zusätzliche modulspezifische Objektfelder bleiben zulässig.