3.0.0 #220
62
backend/src/mcp/documentSchemas.ts
Normal file
62
backend/src/mcp/documentSchemas.ts
Normal file
@@ -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,
|
||||||
|
}
|
||||||
31
backend/src/mcp/pagination.ts
Normal file
31
backend/src/mcp/pagination.ts
Normal file
@@ -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<string, unknown>, 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<string, unknown>) {
|
||||||
|
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<T>(rows: T[], args: Record<string, unknown>, 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 },
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { and, desc, eq, ilike, or } from "drizzle-orm"
|
||||||
import { randomUUID } from "node:crypto"
|
import { randomUUID } from "node:crypto"
|
||||||
import {
|
import {
|
||||||
@@ -27,11 +29,6 @@ import { McpTool } from "../types"
|
|||||||
|
|
||||||
export { prepareStatementAllocationInput } from "../statementAllocations"
|
export { prepareStatementAllocationInput } from "../statementAllocations"
|
||||||
|
|
||||||
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 stringArg = (args: Record<string, unknown>, key: string) => {
|
||||||
const value = args[key]
|
const value = args[key]
|
||||||
@@ -456,7 +453,7 @@ export const accountingTools: McpTool[] = [
|
|||||||
state: { type: "string", description: "Optionaler Statusfilter, z. B. Entwurf oder Gebucht." },
|
state: { type: "string", description: "Optionaler Statusfilter, z. B. Entwurf oder Gebucht." },
|
||||||
customer: { type: "number" },
|
customer: { type: "number" },
|
||||||
project: { type: "number" },
|
project: { type: "number" },
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
...paginationProperties,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
@@ -479,10 +476,11 @@ export const accountingTools: McpTool[] = [
|
|||||||
.select()
|
.select()
|
||||||
.from(createddocuments)
|
.from(createddocuments)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(desc(createddocuments.createdAt))
|
.orderBy(desc(createddocuments.createdAt), createddocuments.id)
|
||||||
.limit(limitFromArgs(args))
|
.limit(limitFromArgs(args) + 1)
|
||||||
|
.offset(offsetFromArgs(args))
|
||||||
|
|
||||||
return { rows }
|
return paginatedResult(rows, args)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -542,12 +540,12 @@ export const accountingTools: McpTool[] = [
|
|||||||
description: { type: "string" },
|
description: { type: "string" },
|
||||||
startText: { type: "string" },
|
startText: { type: "string" },
|
||||||
endText: { type: "string" },
|
endText: { type: "string" },
|
||||||
address: { type: "object" },
|
address: outgoingAddressSchema,
|
||||||
rows: { type: "array" },
|
rows: outgoingRowsSchema,
|
||||||
letterhead: { type: "number" },
|
letterhead: { type: "number" },
|
||||||
availableInPortal: { type: "boolean" },
|
availableInPortal: { type: "boolean" },
|
||||||
customSurchargePercentage: { type: "number" },
|
customSurchargePercentage: { type: "number" },
|
||||||
report: { type: "object" },
|
report: outgoingReportSchema,
|
||||||
serialConfig: serialConfigSchema,
|
serialConfig: serialConfigSchema,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -598,12 +596,12 @@ export const accountingTools: McpTool[] = [
|
|||||||
description: { type: "string" },
|
description: { type: "string" },
|
||||||
startText: { type: "string" },
|
startText: { type: "string" },
|
||||||
endText: { type: "string" },
|
endText: { type: "string" },
|
||||||
address: { type: "object" },
|
address: outgoingAddressSchema,
|
||||||
rows: { type: "array" },
|
rows: outgoingRowsSchema,
|
||||||
letterhead: { type: "number" },
|
letterhead: { type: "number" },
|
||||||
availableInPortal: { type: "boolean" },
|
availableInPortal: { type: "boolean" },
|
||||||
customSurchargePercentage: { type: "number" },
|
customSurchargePercentage: { type: "number" },
|
||||||
report: { type: "object" },
|
report: outgoingReportSchema,
|
||||||
serialConfig: serialConfigSchema,
|
serialConfig: serialConfigSchema,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -720,8 +718,8 @@ export const accountingTools: McpTool[] = [
|
|||||||
enum: ["Standard", "13b UStG", "19 UStG", "12.3 UStG"],
|
enum: ["Standard", "13b UStG", "19 UStG", "12.3 UStG"],
|
||||||
description: "Steuertyp des gesamten Ausgangsdokuments, nicht der einzelne USt-Satz einer Position.",
|
description: "Steuertyp des gesamten Ausgangsdokuments, nicht der einzelne USt-Satz einer Position.",
|
||||||
},
|
},
|
||||||
rows: { type: "array" },
|
rows: outgoingRowsSchema,
|
||||||
report: { type: "object" },
|
report: outgoingReportSchema,
|
||||||
availableInPortal: { type: "boolean" },
|
availableInPortal: { type: "boolean" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -803,7 +801,7 @@ export const accountingTools: McpTool[] = [
|
|||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
status: { type: "string", enum: ["draft", "completed", "error"] },
|
status: { type: "string", enum: ["draft", "completed", "error"] },
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
...paginationProperties,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
@@ -815,10 +813,11 @@ export const accountingTools: McpTool[] = [
|
|||||||
.select()
|
.select()
|
||||||
.from(serialExecutions)
|
.from(serialExecutions)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(desc(serialExecutions.createdAt))
|
.orderBy(desc(serialExecutions.createdAt), serialExecutions.id)
|
||||||
.limit(limitFromArgs(args))
|
.limit(limitFromArgs(args) + 1)
|
||||||
|
.offset(offsetFromArgs(args))
|
||||||
|
|
||||||
return { rows }
|
return paginatedResult(rows, args)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -878,7 +877,7 @@ export const accountingTools: McpTool[] = [
|
|||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
query: { type: "string", description: "Suchtext für Kontonummer, Bezeichnung oder Beschreibung." },
|
query: { type: "string", description: "Suchtext für Kontonummer, Bezeichnung oder Beschreibung." },
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
...paginationProperties,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
@@ -908,10 +907,11 @@ export const accountingTools: McpTool[] = [
|
|||||||
.select()
|
.select()
|
||||||
.from(accounts)
|
.from(accounts)
|
||||||
.where(whereCond)
|
.where(whereCond)
|
||||||
.orderBy(accounts.number)
|
.orderBy(accounts.number, accounts.id)
|
||||||
.limit(limit)
|
.limit(limit + 1)
|
||||||
|
.offset(offsetFromArgs(args))
|
||||||
|
|
||||||
return { accountChart, rows }
|
return { accountChart, ...paginatedResult(rows, args) }
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -924,7 +924,7 @@ export const accountingTools: McpTool[] = [
|
|||||||
properties: {
|
properties: {
|
||||||
state: { type: "string", description: "Optionaler Statusfilter." },
|
state: { type: "string", description: "Optionaler Statusfilter." },
|
||||||
paid: { type: "boolean", description: "Optionaler Zahlungsstatus." },
|
paid: { type: "boolean", description: "Optionaler Zahlungsstatus." },
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
...paginationProperties,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
@@ -939,10 +939,11 @@ export const accountingTools: McpTool[] = [
|
|||||||
.select()
|
.select()
|
||||||
.from(incominginvoices)
|
.from(incominginvoices)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(desc(incominginvoices.createdAt))
|
.orderBy(desc(incominginvoices.createdAt), incominginvoices.id)
|
||||||
.limit(limitFromArgs(args))
|
.limit(limitFromArgs(args) + 1)
|
||||||
|
.offset(offsetFromArgs(args))
|
||||||
|
|
||||||
return { rows }
|
return paginatedResult(rows, args)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -981,6 +982,7 @@ export const accountingTools: McpTool[] = [
|
|||||||
required: ["id"],
|
required: ["id"],
|
||||||
properties: {
|
properties: {
|
||||||
id: { type: "number" },
|
id: { type: "number" },
|
||||||
|
...paginationProperties,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
@@ -995,9 +997,11 @@ export const accountingTools: McpTool[] = [
|
|||||||
eq(files.tenant, context.tenantId),
|
eq(files.tenant, context.tenantId),
|
||||||
eq(files.archived, false)
|
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",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
account: { type: "number", description: "Optionale Bankkonto-ID." },
|
account: { type: "number", description: "Optionale Bankkonto-ID." },
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
...paginationProperties,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
@@ -1326,9 +1330,10 @@ export const accountingTools: McpTool[] = [
|
|||||||
.from(bankstatements)
|
.from(bankstatements)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(desc(bankstatements.date), desc(bankstatements.id))
|
.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: {
|
properties: {
|
||||||
bankstatement: { type: "number" },
|
bankstatement: { type: "number" },
|
||||||
incominginvoice: { type: "number" },
|
incominginvoice: { type: "number" },
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
...paginationProperties,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
@@ -1383,10 +1388,11 @@ export const accountingTools: McpTool[] = [
|
|||||||
.select()
|
.select()
|
||||||
.from(statementallocations)
|
.from(statementallocations)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(desc(statementallocations.created_at))
|
.orderBy(desc(statementallocations.created_at), statementallocations.id)
|
||||||
.limit(limitFromArgs(args))
|
.limit(limitFromArgs(args) + 1)
|
||||||
|
.offset(offsetFromArgs(args))
|
||||||
|
|
||||||
return { rows }
|
return paginatedResult(rows, args)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination"
|
||||||
import { and, desc, eq, ilike, or } from "drizzle-orm"
|
import { and, desc, eq, ilike, or } from "drizzle-orm"
|
||||||
import {
|
import {
|
||||||
branches,
|
branches,
|
||||||
@@ -14,11 +15,6 @@ import {
|
|||||||
} from "../../../db/schema"
|
} from "../../../db/schema"
|
||||||
import { McpTool } from "../types"
|
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 stringArg = (args: Record<string, unknown>, key: string) => {
|
||||||
const value = args[key]
|
const value = args[key]
|
||||||
@@ -71,7 +67,7 @@ export const masterdataTools: McpTool[] = [
|
|||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
query: { type: "string", description: "Suchtext für Name, Lieferantennummer oder Notizen." },
|
query: { type: "string", description: "Suchtext für Name, Lieferantennummer oder Notizen." },
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
...paginationProperties,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
@@ -91,10 +87,11 @@ export const masterdataTools: McpTool[] = [
|
|||||||
.select()
|
.select()
|
||||||
.from(vendors)
|
.from(vendors)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(vendors.name)
|
.orderBy(vendors.name, vendors.id)
|
||||||
.limit(limitFromArgs(args))
|
.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." },
|
query: { type: "string", description: "Suchtext für Name, E-Mail, Telefon, Rolle oder Notizen." },
|
||||||
customer: { type: "number" },
|
customer: { type: "number" },
|
||||||
vendor: { type: "number" },
|
vendor: { type: "number" },
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
...paginationProperties,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
@@ -161,10 +158,11 @@ export const masterdataTools: McpTool[] = [
|
|||||||
.select()
|
.select()
|
||||||
.from(contacts)
|
.from(contacts)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(contacts.fullName)
|
.orderBy(contacts.fullName, contacts.id)
|
||||||
.limit(limitFromArgs(args))
|
.limit(limitFromArgs(args) + 1)
|
||||||
|
.offset(offsetFromArgs(args))
|
||||||
|
|
||||||
return { rows }
|
return paginatedResult(rows, args)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -176,7 +174,7 @@ export const masterdataTools: McpTool[] = [
|
|||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
query: { type: "string", description: "Suchtext für Name, Artikelnummer, Hersteller, EAN, Barcode oder Beschreibung." },
|
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) {
|
async handler(context, args) {
|
||||||
@@ -200,10 +198,11 @@ export const masterdataTools: McpTool[] = [
|
|||||||
.select()
|
.select()
|
||||||
.from(products)
|
.from(products)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(products.name)
|
.orderBy(products.name, products.id)
|
||||||
.limit(limitFromArgs(args))
|
.limit(limitFromArgs(args) + 1)
|
||||||
|
.offset(offsetFromArgs(args))
|
||||||
|
|
||||||
return { rows }
|
return paginatedResult(rows, args)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -239,7 +238,7 @@ export const masterdataTools: McpTool[] = [
|
|||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
query: { type: "string", description: "Suchtext für Name, Leistungsnummer oder Beschreibung." },
|
query: { type: "string", description: "Suchtext für Name, Leistungsnummer oder Beschreibung." },
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
...paginationProperties,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
@@ -258,10 +257,11 @@ export const masterdataTools: McpTool[] = [
|
|||||||
.select()
|
.select()
|
||||||
.from(services)
|
.from(services)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(services.name)
|
.orderBy(services.name, services.id)
|
||||||
.limit(limitFromArgs(args))
|
.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." },
|
query: { type: "string", description: "Suchtext für Nummer, Name oder Beschreibung." },
|
||||||
branch: { type: "number" },
|
branch: { type: "number" },
|
||||||
project: { type: "number" },
|
project: { type: "number" },
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
...paginationProperties,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
@@ -323,10 +323,11 @@ export const masterdataTools: McpTool[] = [
|
|||||||
.select()
|
.select()
|
||||||
.from(costcentres)
|
.from(costcentres)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(costcentres.number)
|
.orderBy(costcentres.number, costcentres.id)
|
||||||
.limit(limitFromArgs(args))
|
.limit(limitFromArgs(args) + 1)
|
||||||
|
.offset(offsetFromArgs(args))
|
||||||
|
|
||||||
return { rows }
|
return paginatedResult(rows, args)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -362,7 +363,7 @@ export const masterdataTools: McpTool[] = [
|
|||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
query: { type: "string", description: "Suchtext für Nummer, Name oder Beschreibung." },
|
query: { type: "string", description: "Suchtext für Nummer, Name oder Beschreibung." },
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
...paginationProperties,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
@@ -382,10 +383,11 @@ export const masterdataTools: McpTool[] = [
|
|||||||
.select()
|
.select()
|
||||||
.from(branches)
|
.from(branches)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(branches.name)
|
.orderBy(branches.name, branches.id)
|
||||||
.limit(limitFromArgs(args))
|
.limit(limitFromArgs(args) + 1)
|
||||||
|
.offset(offsetFromArgs(args))
|
||||||
|
|
||||||
return { rows }
|
return paginatedResult(rows, args)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -398,7 +400,7 @@ export const masterdataTools: McpTool[] = [
|
|||||||
properties: {
|
properties: {
|
||||||
query: { type: "string", description: "Suchtext für Name oder Beschreibung." },
|
query: { type: "string", description: "Suchtext für Name oder Beschreibung." },
|
||||||
branch: { type: "number" },
|
branch: { type: "number" },
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
...paginationProperties,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
@@ -419,10 +421,11 @@ export const masterdataTools: McpTool[] = [
|
|||||||
.select()
|
.select()
|
||||||
.from(teams)
|
.from(teams)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(teams.name)
|
.orderBy(teams.name, teams.id)
|
||||||
.limit(limitFromArgs(args))
|
.limit(limitFromArgs(args) + 1)
|
||||||
|
.offset(offsetFromArgs(args))
|
||||||
|
|
||||||
return { rows }
|
return paginatedResult(rows, args)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -434,7 +437,7 @@ export const masterdataTools: McpTool[] = [
|
|||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
query: { type: "string", description: "Suchtext für Name, Kennzeichen, FIN oder Farbe." },
|
query: { type: "string", description: "Suchtext für Name, Kennzeichen, FIN oder Farbe." },
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
...paginationProperties,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
@@ -455,10 +458,11 @@ export const masterdataTools: McpTool[] = [
|
|||||||
.select()
|
.select()
|
||||||
.from(vehicles)
|
.from(vehicles)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(vehicles.name)
|
.orderBy(vehicles.name, vehicles.id)
|
||||||
.limit(limitFromArgs(args))
|
.limit(limitFromArgs(args) + 1)
|
||||||
|
.offset(offsetFromArgs(args))
|
||||||
|
|
||||||
return { rows }
|
return paginatedResult(rows, args)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -471,7 +475,7 @@ export const masterdataTools: McpTool[] = [
|
|||||||
properties: {
|
properties: {
|
||||||
query: { type: "string", description: "Suchtext für Name, Artikelnummer, Seriennummer, Hersteller oder Beschreibung." },
|
query: { type: "string", description: "Suchtext für Name, Artikelnummer, Seriennummer, Hersteller oder Beschreibung." },
|
||||||
vendor: { type: "number" },
|
vendor: { type: "number" },
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
...paginationProperties,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
@@ -496,10 +500,11 @@ export const masterdataTools: McpTool[] = [
|
|||||||
.select()
|
.select()
|
||||||
.from(inventoryitems)
|
.from(inventoryitems)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(inventoryitems.name)
|
.orderBy(inventoryitems.name, inventoryitems.id)
|
||||||
.limit(limitFromArgs(args))
|
.limit(limitFromArgs(args) + 1)
|
||||||
|
.offset(offsetFromArgs(args))
|
||||||
|
|
||||||
return { rows }
|
return paginatedResult(rows, args)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -535,7 +540,7 @@ export const masterdataTools: McpTool[] = [
|
|||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
query: { type: "string", description: "Suchtext für Name, Singular, Plural oder Kürzel." },
|
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) {
|
async handler(context, args) {
|
||||||
@@ -552,10 +557,11 @@ export const masterdataTools: McpTool[] = [
|
|||||||
ilike(units.short, `%${query}%`)
|
ilike(units.short, `%${query}%`)
|
||||||
)
|
)
|
||||||
: undefined)
|
: undefined)
|
||||||
.orderBy(units.name)
|
.orderBy(units.name, units.id)
|
||||||
.limit(limitFromArgs(args))
|
.limit(limitFromArgs(args) + 1)
|
||||||
|
.offset(offsetFromArgs(args))
|
||||||
|
|
||||||
return { rows }
|
return paginatedResult(rows, args)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
|
import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination"
|
||||||
import { and, desc, eq, ilike, or } from "drizzle-orm"
|
import { and, desc, eq, ilike, or } from "drizzle-orm"
|
||||||
import { customers, events, plants, projects, tasks } from "../../../db/schema"
|
import { customers, events, plants, projects, tasks } from "../../../db/schema"
|
||||||
import { insertHistoryItem } from "../../utils/history"
|
import { insertHistoryItem } from "../../utils/history"
|
||||||
import { activateProjectPhase } from "../projectPhases"
|
import { activateProjectPhase } from "../projectPhases"
|
||||||
import { McpTool } from "../types"
|
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 stringArg = (args: Record<string, unknown>, key: string) => {
|
||||||
const value = args[key]
|
const value = args[key]
|
||||||
@@ -30,7 +26,7 @@ export const organisationTools: McpTool[] = [
|
|||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
query: { type: "string", description: "Suchtext für Name, Kundennummer, Vorname, Nachname oder Notizen." },
|
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) {
|
async handler(context, args) {
|
||||||
@@ -62,10 +58,11 @@ export const organisationTools: McpTool[] = [
|
|||||||
})
|
})
|
||||||
.from(customers)
|
.from(customers)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(customers.name)
|
.orderBy(customers.name, customers.id)
|
||||||
.limit(limitFromArgs(args))
|
.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." },
|
query: { type: "string", description: "Suchtext für Name, Projektnummer, Kundenreferenz oder Notizen." },
|
||||||
customer: { type: "number" },
|
customer: { type: "number" },
|
||||||
activePhase: { type: "string" },
|
activePhase: { type: "string" },
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
...paginationProperties,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
@@ -104,10 +101,11 @@ export const organisationTools: McpTool[] = [
|
|||||||
.select()
|
.select()
|
||||||
.from(projects)
|
.from(projects)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(desc(projects.createdAt))
|
.orderBy(desc(projects.createdAt), projects.id)
|
||||||
.limit(limitFromArgs(args))
|
.limit(limitFromArgs(args) + 1)
|
||||||
|
.offset(offsetFromArgs(args))
|
||||||
|
|
||||||
return { rows }
|
return paginatedResult(rows, args)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -217,7 +215,7 @@ export const organisationTools: McpTool[] = [
|
|||||||
properties: {
|
properties: {
|
||||||
query: { type: "string", description: "Suchtext für Name." },
|
query: { type: "string", description: "Suchtext für Name." },
|
||||||
customer: { type: "number" },
|
customer: { type: "number" },
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
...paginationProperties,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
@@ -233,10 +231,11 @@ export const organisationTools: McpTool[] = [
|
|||||||
.select()
|
.select()
|
||||||
.from(plants)
|
.from(plants)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(plants.name)
|
.orderBy(plants.name, plants.id)
|
||||||
.limit(limitFromArgs(args))
|
.limit(limitFromArgs(args) + 1)
|
||||||
|
.offset(offsetFromArgs(args))
|
||||||
|
|
||||||
return { rows }
|
return paginatedResult(rows, args)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -251,7 +250,7 @@ export const organisationTools: McpTool[] = [
|
|||||||
project: { type: "number" },
|
project: { type: "number" },
|
||||||
customer: { type: "number" },
|
customer: { type: "number" },
|
||||||
eventtype: { type: "string" },
|
eventtype: { type: "string" },
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
...paginationProperties,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
@@ -277,10 +276,11 @@ export const organisationTools: McpTool[] = [
|
|||||||
.select()
|
.select()
|
||||||
.from(events)
|
.from(events)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(desc(events.startDate))
|
.orderBy(desc(events.startDate), events.id)
|
||||||
.limit(limitFromArgs(args))
|
.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." },
|
query: { type: "string", description: "Suchtext für Name, Beschreibung oder Kategorie." },
|
||||||
project: { type: "number" },
|
project: { type: "number" },
|
||||||
customer: { type: "number" },
|
customer: { type: "number" },
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
...paginationProperties,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
@@ -318,10 +318,11 @@ export const organisationTools: McpTool[] = [
|
|||||||
.select()
|
.select()
|
||||||
.from(tasks)
|
.from(tasks)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(desc(tasks.createdAt))
|
.orderBy(desc(tasks.createdAt), tasks.id)
|
||||||
.limit(limitFromArgs(args))
|
.limit(limitFromArgs(args) + 1)
|
||||||
|
.offset(offsetFromArgs(args))
|
||||||
|
|
||||||
return { rows }
|
return paginatedResult(rows, args)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination"
|
||||||
import { and, asc, eq, ilike, isNull } from "drizzle-orm"
|
import { and, asc, eq, ilike, isNull } from "drizzle-orm"
|
||||||
import { wikiPages } from "../../../db/schema"
|
import { wikiPages } from "../../../db/schema"
|
||||||
import { McpContext, McpTool } from "../types"
|
import { McpContext, McpTool } from "../types"
|
||||||
@@ -27,17 +28,7 @@ const pageIdArg = (args: Record<string, unknown>, key = "id") => {
|
|||||||
return value
|
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 {
|
export function prepareWikiScope(args: Record<string, unknown>): WikiScope {
|
||||||
const entityType = stringArg(args, "entityType")
|
const entityType = stringArg(args, "entityType")
|
||||||
@@ -144,8 +135,8 @@ export const wikiTools: McpTool[] = [
|
|||||||
...entityScopeProperties,
|
...entityScopeProperties,
|
||||||
query: { type: "string", description: "Optionaler Suchtext im Titel." },
|
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." },
|
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 },
|
...paginationProperties,
|
||||||
offset: { type: "integer", minimum: 0, default: 0 },
|
limit: { ...paginationProperties.limit, default: 50 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
@@ -173,11 +164,11 @@ export const wikiTools: McpTool[] = [
|
|||||||
})
|
})
|
||||||
.from(wikiPages)
|
.from(wikiPages)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(asc(wikiPages.sortOrder), asc(wikiPages.title))
|
.orderBy(asc(wikiPages.sortOrder), asc(wikiPages.title), wikiPages.id)
|
||||||
.limit(limitFromArgs(args))
|
.limit(limitFromArgs(args, 50) + 1)
|
||||||
.offset(offsetFromArgs(args))
|
.offset(offsetFromArgs(args))
|
||||||
|
|
||||||
return { rows }
|
return paginatedResult(rows, args, 50)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
63
backend/tests/mcpPagination.test.ts
Normal file
63
backend/tests/mcpPagination.test.ts
Normal file
@@ -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 })
|
||||||
|
})
|
||||||
19
docs/technik/mcp.md
Normal file
19
docs/technik/mcp.md
Normal file
@@ -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.
|
||||||
Reference in New Issue
Block a user