buchpilot-mcp 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,392 @@
1
+ import type {
2
+ IAccountingBackend,
3
+ Contact, ContactInput, ContactFilter,
4
+ Invoice, InvoiceInput, InvoiceFilter, LineItem,
5
+ Voucher, VoucherInput, VoucherFilter,
6
+ Quotation, QuotationInput,
7
+ } from "./types.js";
8
+ import { ErrorCode, createError } from "../errors.js";
9
+
10
+ const BASE_URL = "https://api.lexoffice.io/v1";
11
+
12
+ // Rate limiter: Lexoffice allows max 2 requests/second
13
+ let lastRequestTime = 0;
14
+
15
+ async function rateLimitSleep(): Promise<void> {
16
+ const now = Date.now();
17
+ const elapsed = now - lastRequestTime;
18
+ if (elapsed < 500) {
19
+ await new Promise((resolve) => setTimeout(resolve, 500 - elapsed));
20
+ }
21
+ lastRequestTime = Date.now();
22
+ }
23
+
24
+ async function lexofficeRequest(
25
+ apiKey: string,
26
+ method: string,
27
+ endpoint: string,
28
+ body?: Record<string, unknown>,
29
+ qs?: Record<string, string>,
30
+ ): Promise<any> {
31
+ await rateLimitSleep();
32
+
33
+ const url = new URL(`${BASE_URL}${endpoint}`);
34
+ if (qs) {
35
+ for (const [key, value] of Object.entries(qs)) {
36
+ url.searchParams.set(key, value);
37
+ }
38
+ }
39
+
40
+ const headers: Record<string, string> = {
41
+ Authorization: `Bearer ${apiKey}`,
42
+ Accept: "application/json",
43
+ };
44
+
45
+ const options: RequestInit = { method, headers };
46
+
47
+ if (body && method !== "GET") {
48
+ headers["Content-Type"] = "application/json";
49
+ options.body = JSON.stringify(body);
50
+ }
51
+
52
+ const response = await fetch(url.toString(), options);
53
+
54
+ if (response.status === 401) {
55
+ throw createError(ErrorCode.AUTH_FAILED, "Lexoffice API key is invalid or expired");
56
+ }
57
+ if (response.status === 429) {
58
+ throw createError(ErrorCode.RATE_LIMITED, "Lexoffice API rate limit exceeded (max 2 req/s)");
59
+ }
60
+ if (response.status === 404) {
61
+ throw createError(ErrorCode.NOT_FOUND, `Resource not found: ${endpoint}`);
62
+ }
63
+
64
+ if (!response.ok) {
65
+ const errorText = await response.text().catch(() => "Unknown error");
66
+ throw createError(ErrorCode.BACKEND_ERROR, `Lexoffice API error ${response.status}: ${errorText}`);
67
+ }
68
+
69
+ if (response.status === 204) return {};
70
+
71
+ const contentType = response.headers.get("content-type") || "";
72
+ if (contentType.includes("application/json")) {
73
+ return response.json();
74
+ }
75
+ return response;
76
+ }
77
+
78
+ // === Mapping Helpers ===
79
+
80
+ function toLexofficeContact(input: ContactInput): Record<string, unknown> {
81
+ const roles: Record<string, Record<string, never>> = {};
82
+ const role = input.role || "customer";
83
+ if (role === "customer" || role === "both") roles.customer = {};
84
+ if (role === "vendor" || role === "both") roles.vendor = {};
85
+
86
+ const body: Record<string, unknown> = { version: 0, roles };
87
+
88
+ if (input.type === "company") {
89
+ body.company = {
90
+ name: input.name,
91
+ ...(input.email ? { contactPersons: [{ emailAddress: input.email }] } : {}),
92
+ };
93
+ } else {
94
+ body.person = {
95
+ lastName: input.name,
96
+ ...(input.firstName ? { firstName: input.firstName } : {}),
97
+ };
98
+ if (input.email) {
99
+ body.emailAddresses = { business: [input.email] };
100
+ }
101
+ }
102
+
103
+ return body;
104
+ }
105
+
106
+ function fromLexofficeContact(raw: any): Contact {
107
+ const isCompany = !!raw.company;
108
+ return {
109
+ id: raw.id,
110
+ type: isCompany ? "company" : "person",
111
+ name: isCompany ? raw.company?.name : raw.person?.lastName,
112
+ firstName: raw.person?.firstName,
113
+ email: isCompany
114
+ ? raw.company?.contactPersons?.[0]?.emailAddress
115
+ : raw.emailAddresses?.business?.[0] || raw.emailAddresses?.private?.[0],
116
+ role: raw.roles?.customer && raw.roles?.vendor
117
+ ? "both"
118
+ : raw.roles?.vendor ? "vendor" : "customer",
119
+ backend: "lexoffice",
120
+ raw,
121
+ };
122
+ }
123
+
124
+ function toLexofficeInvoice(input: InvoiceInput): Record<string, unknown> {
125
+ return {
126
+ voucherDate: input.date,
127
+ address: { contactId: input.contactId },
128
+ lineItems: input.lineItems.map((item) => ({
129
+ type: "custom",
130
+ name: item.name,
131
+ quantity: item.quantity,
132
+ unitName: "Stueck",
133
+ unitPrice: {
134
+ currency: input.currency || "EUR",
135
+ netAmount: item.unitPrice,
136
+ taxRatePercentage: item.taxRate,
137
+ },
138
+ })),
139
+ totalPrice: { currency: input.currency || "EUR" },
140
+ taxConditions: { taxType: "net" },
141
+ ...(input.title && { title: input.title }),
142
+ ...(input.introduction && { introduction: input.introduction }),
143
+ ...(input.remark && { remark: input.remark }),
144
+ };
145
+ }
146
+
147
+ function fromLexofficeInvoice(raw: any): Invoice {
148
+ const lineItems: LineItem[] = (raw.lineItems || [])
149
+ .filter((li: any) => li.type === "custom")
150
+ .map((li: any) => ({
151
+ name: li.name,
152
+ quantity: li.quantity,
153
+ unitPrice: li.unitPrice?.netAmount || 0,
154
+ taxRate: li.unitPrice?.taxRatePercentage || 0,
155
+ }));
156
+
157
+ return {
158
+ id: raw.id,
159
+ number: raw.voucherNumber || "",
160
+ contactId: raw.address?.contactId || "",
161
+ date: raw.voucherDate || "",
162
+ dueDate: raw.dueDate || raw.voucherDate || "",
163
+ status: raw.voucherStatus === "draft" ? "draft"
164
+ : raw.voucherStatus === "open" ? "open"
165
+ : raw.voucherStatus === "paid" ? "paid"
166
+ : raw.voucherStatus === "overdue" ? "overdue"
167
+ : "open",
168
+ totalNet: raw.totalPrice?.totalNetAmount || 0,
169
+ totalGross: raw.totalPrice?.totalGrossAmount || 0,
170
+ currency: raw.totalPrice?.currency || "EUR",
171
+ lineItems,
172
+ backend: "lexoffice",
173
+ raw,
174
+ };
175
+ }
176
+
177
+ function fromLexofficeVoucher(raw: any): Voucher {
178
+ return {
179
+ id: raw.id,
180
+ type: raw.voucherType || "",
181
+ date: raw.voucherDate || "",
182
+ totalGross: raw.totalGrossAmount || 0,
183
+ taxRate: raw.voucherItems?.[0]?.taxRatePercent || 0,
184
+ backend: "lexoffice",
185
+ raw,
186
+ };
187
+ }
188
+
189
+ function fromLexofficeQuotation(raw: any): Quotation {
190
+ const lineItems: LineItem[] = (raw.lineItems || [])
191
+ .filter((li: any) => li.type === "custom")
192
+ .map((li: any) => ({
193
+ name: li.name,
194
+ quantity: li.quantity,
195
+ unitPrice: li.unitPrice?.netAmount || 0,
196
+ taxRate: li.unitPrice?.taxRatePercentage || 0,
197
+ }));
198
+
199
+ return {
200
+ id: raw.id,
201
+ contactId: raw.address?.contactId || "",
202
+ date: raw.voucherDate || "",
203
+ expirationDate: raw.expirationDate,
204
+ totalNet: raw.totalPrice?.totalNetAmount || 0,
205
+ totalGross: raw.totalPrice?.totalGrossAmount || 0,
206
+ lineItems,
207
+ backend: "lexoffice",
208
+ raw,
209
+ };
210
+ }
211
+
212
+ // === Lexoffice Backend ===
213
+
214
+ export class LexofficeBackend implements IAccountingBackend {
215
+ name = "lexoffice";
216
+ private apiKey: string;
217
+
218
+ constructor(apiKey: string) {
219
+ this.apiKey = apiKey;
220
+ }
221
+
222
+ // --- Contacts ---
223
+
224
+ async createContact(input: ContactInput): Promise<Contact> {
225
+ const body = toLexofficeContact(input);
226
+ const result = await lexofficeRequest(this.apiKey, "POST", "/contacts", body);
227
+ return this.getContact(result.id);
228
+ }
229
+
230
+ async getContact(id: string): Promise<Contact> {
231
+ const raw = await lexofficeRequest(this.apiKey, "GET", `/contacts/${id}`);
232
+ return fromLexofficeContact(raw);
233
+ }
234
+
235
+ async listContacts(filter?: ContactFilter): Promise<Contact[]> {
236
+ const qs: Record<string, string> = { size: String(filter?.limit || 25) };
237
+ if (filter?.email) qs.email = filter.email;
238
+ if (filter?.name) qs.name = filter.name;
239
+ if (filter?.role === "customer") qs.customer = "true";
240
+ if (filter?.role === "vendor") qs.vendor = "true";
241
+ const response = await lexofficeRequest(this.apiKey, "GET", "/contacts", undefined, qs);
242
+ return (response.content || []).map(fromLexofficeContact);
243
+ }
244
+
245
+ async updateContact(id: string, input: Partial<ContactInput>): Promise<Contact> {
246
+ const existing = await lexofficeRequest(this.apiKey, "GET", `/contacts/${id}`);
247
+ const body = { ...existing, ...input };
248
+ delete body.id;
249
+ delete body.resourceUri;
250
+ if (input.name && body.company) {
251
+ body.company.name = input.name;
252
+ }
253
+ if (input.name && body.person) {
254
+ body.person.lastName = input.name;
255
+ }
256
+ await lexofficeRequest(this.apiKey, "PUT", `/contacts/${id}`, body);
257
+ return this.getContact(id);
258
+ }
259
+
260
+ // --- Invoices ---
261
+
262
+ async createInvoice(input: InvoiceInput): Promise<Invoice> {
263
+ const body = toLexofficeInvoice(input);
264
+ const qs: Record<string, string> = {};
265
+ if (input.finalize) qs.finalize = "true";
266
+ const result = await lexofficeRequest(this.apiKey, "POST", "/invoices", body, Object.keys(qs).length ? qs : undefined);
267
+ return this.getInvoice(result.id);
268
+ }
269
+
270
+ async getInvoice(id: string): Promise<Invoice> {
271
+ const raw = await lexofficeRequest(this.apiKey, "GET", `/invoices/${id}`);
272
+ return fromLexofficeInvoice(raw);
273
+ }
274
+
275
+ async listInvoices(filter?: InvoiceFilter): Promise<Invoice[]> {
276
+ const qs: Record<string, string> = { size: String(filter?.limit || 25) };
277
+ if (filter?.status && filter.status !== "overdue") {
278
+ qs.voucherStatus = filter.status;
279
+ }
280
+ if (filter?.contactId) {
281
+ qs.contactId = filter.contactId;
282
+ }
283
+ const response = await lexofficeRequest(this.apiKey, "GET", "/voucherlist", undefined, {
284
+ ...qs,
285
+ voucherType: "invoice",
286
+ });
287
+ const items: Invoice[] = (response.content || []).map(fromLexofficeInvoice);
288
+ if (filter?.status === "overdue") {
289
+ const now = Date.now();
290
+ return items.filter((inv: Invoice) => new Date(inv.dueDate).getTime() < now && inv.status !== "paid");
291
+ }
292
+ return items;
293
+ }
294
+
295
+ async getInvoicePdf(id: string): Promise<{ data: Buffer; filename: string }> {
296
+ const docResponse = await lexofficeRequest(this.apiKey, "GET", `/invoices/${id}/document`);
297
+ const fileId = docResponse.documentFileId;
298
+ await rateLimitSleep();
299
+ const fileResponse = await fetch(`${BASE_URL}/files/${fileId}`, {
300
+ headers: { Authorization: `Bearer ${this.apiKey}` },
301
+ });
302
+ if (fileResponse.status === 429) {
303
+ throw createError(ErrorCode.RATE_LIMITED, "Lexoffice API rate limit exceeded during PDF download");
304
+ }
305
+ if (!fileResponse.ok) {
306
+ throw createError(ErrorCode.BACKEND_ERROR, `Failed to download PDF: ${fileResponse.status}`);
307
+ }
308
+ const buffer = Buffer.from(await fileResponse.arrayBuffer());
309
+ return { data: buffer, filename: `invoice-${id}.pdf` };
310
+ }
311
+
312
+ async updateInvoice(id: string, input: Partial<InvoiceInput>): Promise<Invoice> {
313
+ const existing = await lexofficeRequest(this.apiKey, "GET", `/invoices/${id}`);
314
+ const body = { ...existing, ...input };
315
+ delete body.id;
316
+ delete body.resourceUri;
317
+ delete body.organizationId;
318
+ await lexofficeRequest(this.apiKey, "PUT", `/invoices/${id}`, body);
319
+ return this.getInvoice(id);
320
+ }
321
+
322
+ // --- Vouchers ---
323
+
324
+ async createVoucher(input: VoucherInput): Promise<Voucher> {
325
+ const taxAmount = input.totalGross - (input.totalGross / (1 + input.taxRate / 100));
326
+ const body = {
327
+ type: "voucher",
328
+ voucherType: input.type,
329
+ voucherDate: input.date,
330
+ totalGrossAmount: input.totalGross,
331
+ totalTaxAmount: taxAmount,
332
+ taxType: "gross",
333
+ voucherItems: [{
334
+ amount: input.totalGross,
335
+ taxAmount,
336
+ taxRatePercent: input.taxRate,
337
+ categoryId: "8f8664a8-fd86-11e1-a21f-0800200c9a66",
338
+ }],
339
+ ...(input.contactId && { contactId: input.contactId }),
340
+ ...(input.remark && { remark: input.remark }),
341
+ };
342
+ const result = await lexofficeRequest(this.apiKey, "POST", "/vouchers", body);
343
+ return this.getVoucher(result.id);
344
+ }
345
+
346
+ async getVoucher(id: string): Promise<Voucher> {
347
+ const raw = await lexofficeRequest(this.apiKey, "GET", `/vouchers/${id}`);
348
+ return fromLexofficeVoucher(raw);
349
+ }
350
+
351
+ async listVouchers(filter?: VoucherFilter): Promise<Voucher[]> {
352
+ const qs: Record<string, string> = { size: String(filter?.limit || 25) };
353
+ if (filter?.type && filter.type !== "any") qs.voucherType = filter.type;
354
+ const response = await lexofficeRequest(this.apiKey, "GET", "/voucherlist", undefined, qs);
355
+ return (response.content || []).map(fromLexofficeVoucher);
356
+ }
357
+
358
+ // --- Quotations ---
359
+
360
+ async createQuotation(input: QuotationInput): Promise<Quotation> {
361
+ const body = {
362
+ voucherDate: input.date,
363
+ address: { contactId: input.contactId },
364
+ lineItems: input.lineItems.map((item) => ({
365
+ type: "custom",
366
+ name: item.name,
367
+ quantity: item.quantity,
368
+ unitName: "Stueck",
369
+ unitPrice: {
370
+ currency: "EUR",
371
+ netAmount: item.unitPrice,
372
+ taxRatePercentage: item.taxRate,
373
+ },
374
+ })),
375
+ totalPrice: { currency: "EUR" },
376
+ taxConditions: { taxType: "net" },
377
+ ...(input.expirationDate && { expirationDate: input.expirationDate }),
378
+ ...(input.title && { title: input.title }),
379
+ ...(input.introduction && { introduction: input.introduction }),
380
+ ...(input.remark && { remark: input.remark }),
381
+ };
382
+ const qs: Record<string, string> = {};
383
+ if (input.finalize) qs.finalize = "true";
384
+ const result = await lexofficeRequest(this.apiKey, "POST", "/quotations", body, Object.keys(qs).length ? qs : undefined);
385
+ return this.getQuotation(result.id);
386
+ }
387
+
388
+ async getQuotation(id: string): Promise<Quotation> {
389
+ const raw = await lexofficeRequest(this.apiKey, "GET", `/quotations/${id}`);
390
+ return fromLexofficeQuotation(raw);
391
+ }
392
+ }
@@ -0,0 +1,137 @@
1
+ // === Gemeinsame Entity-Typen ===
2
+
3
+ export interface Contact {
4
+ id: string;
5
+ type: "person" | "company";
6
+ name: string;
7
+ firstName?: string;
8
+ email?: string;
9
+ role: "customer" | "vendor" | "both";
10
+ backend: string;
11
+ raw: Record<string, unknown>;
12
+ }
13
+
14
+ export interface ContactInput {
15
+ type: "person" | "company";
16
+ name: string;
17
+ firstName?: string;
18
+ email?: string;
19
+ role?: "customer" | "vendor" | "both";
20
+ }
21
+
22
+ export interface ContactFilter {
23
+ name?: string;
24
+ email?: string;
25
+ role?: "customer" | "vendor";
26
+ limit?: number;
27
+ }
28
+
29
+ export interface LineItem {
30
+ name: string;
31
+ quantity: number;
32
+ unitPrice: number;
33
+ taxRate: number;
34
+ }
35
+
36
+ export interface Invoice {
37
+ id: string;
38
+ number: string;
39
+ contactId: string;
40
+ date: string;
41
+ dueDate: string;
42
+ status: "draft" | "open" | "paid" | "overdue";
43
+ totalNet: number;
44
+ totalGross: number;
45
+ currency: string;
46
+ lineItems: LineItem[];
47
+ backend: string;
48
+ raw: Record<string, unknown>;
49
+ }
50
+
51
+ export interface InvoiceInput {
52
+ contactId: string;
53
+ date: string;
54
+ lineItems: LineItem[];
55
+ title?: string;
56
+ introduction?: string;
57
+ remark?: string;
58
+ currency?: string;
59
+ finalize?: boolean;
60
+ }
61
+
62
+ export interface InvoiceFilter {
63
+ status?: "draft" | "open" | "paid" | "overdue";
64
+ contactId?: string;
65
+ limit?: number;
66
+ }
67
+
68
+ export interface Voucher {
69
+ id: string;
70
+ type: string;
71
+ date: string;
72
+ totalGross: number;
73
+ taxRate: number;
74
+ backend: string;
75
+ raw: Record<string, unknown>;
76
+ }
77
+
78
+ export interface VoucherInput {
79
+ type: "purchaseinvoice" | "purchasecreditnote" | "any";
80
+ date: string;
81
+ totalGross: number;
82
+ taxRate: number;
83
+ contactId?: string;
84
+ remark?: string;
85
+ }
86
+
87
+ export interface VoucherFilter {
88
+ type?: "purchaseinvoice" | "purchasecreditnote" | "any";
89
+ limit?: number;
90
+ }
91
+
92
+ export interface Quotation {
93
+ id: string;
94
+ contactId: string;
95
+ date: string;
96
+ expirationDate?: string;
97
+ totalNet: number;
98
+ totalGross: number;
99
+ lineItems: LineItem[];
100
+ backend: string;
101
+ raw: Record<string, unknown>;
102
+ }
103
+
104
+ export interface QuotationInput {
105
+ contactId: string;
106
+ date: string;
107
+ lineItems: LineItem[];
108
+ expirationDate?: string;
109
+ title?: string;
110
+ introduction?: string;
111
+ remark?: string;
112
+ finalize?: boolean;
113
+ }
114
+
115
+ // === Backend Interface ===
116
+
117
+ export interface IAccountingBackend {
118
+ name: string;
119
+
120
+ createContact(input: ContactInput): Promise<Contact>;
121
+ getContact(id: string): Promise<Contact>;
122
+ listContacts(filter?: ContactFilter): Promise<Contact[]>;
123
+ updateContact(id: string, input: Partial<ContactInput>): Promise<Contact>;
124
+
125
+ createInvoice(input: InvoiceInput): Promise<Invoice>;
126
+ getInvoice(id: string): Promise<Invoice>;
127
+ listInvoices(filter?: InvoiceFilter): Promise<Invoice[]>;
128
+ getInvoicePdf(id: string): Promise<{ data: Buffer; filename: string }>;
129
+ updateInvoice(id: string, input: Partial<InvoiceInput>): Promise<Invoice>;
130
+
131
+ createVoucher(input: VoucherInput): Promise<Voucher>;
132
+ getVoucher(id: string): Promise<Voucher>;
133
+ listVouchers(filter?: VoucherFilter): Promise<Voucher[]>;
134
+
135
+ createQuotation(input: QuotationInput): Promise<Quotation>;
136
+ getQuotation(id: string): Promise<Quotation>;
137
+ }
package/src/config.ts ADDED
@@ -0,0 +1,62 @@
1
+ import { readFileSync, existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { z } from "zod";
5
+ import { ErrorCode, createError } from "./errors.js";
6
+
7
+ const BackendConfigSchema = z.object({
8
+ api_key: z.string().min(1, "API key must not be empty"),
9
+ });
10
+
11
+ const ConfigSchema = z.object({
12
+ backends: z.record(z.string(), BackendConfigSchema),
13
+ default_backend: z.string(),
14
+ });
15
+
16
+ export type BuchPilotConfig = z.infer<typeof ConfigSchema>;
17
+
18
+ export function loadConfig(): BuchPilotConfig {
19
+ const configPaths = [
20
+ process.env.BUCHPILOT_CONFIG,
21
+ join(homedir(), ".buchpilot.json"),
22
+ join(process.cwd(), ".buchpilot.json"),
23
+ ].filter(Boolean) as string[];
24
+
25
+ for (const configPath of configPaths) {
26
+ if (existsSync(configPath)) {
27
+ try {
28
+ const raw = readFileSync(configPath, "utf-8");
29
+ const parsed = JSON.parse(raw);
30
+ const validated = ConfigSchema.parse(parsed);
31
+
32
+ if (!validated.backends[validated.default_backend]) {
33
+ throw createError(
34
+ ErrorCode.BACKEND_NOT_CONFIGURED,
35
+ `Default backend "${validated.default_backend}" is not configured in backends`,
36
+ );
37
+ }
38
+
39
+ return validated;
40
+ } catch (err) {
41
+ if (err && typeof err === "object" && "code" in err) {
42
+ throw err;
43
+ }
44
+ throw createError(
45
+ ErrorCode.CONFIG_MISSING,
46
+ `Invalid config at ${configPath}: ${err instanceof Error ? err.message : String(err)}`,
47
+ );
48
+ }
49
+ }
50
+ }
51
+
52
+ throw createError(
53
+ ErrorCode.CONFIG_MISSING,
54
+ "No .buchpilot.json found. Create one at ~/.buchpilot.json or ./.buchpilot.json with your API keys.",
55
+ {
56
+ example: {
57
+ backends: { lexoffice: { api_key: "YOUR_KEY" } },
58
+ default_backend: "lexoffice",
59
+ },
60
+ },
61
+ );
62
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,34 @@
1
+ export enum ErrorCode {
2
+ AUTH_FAILED = "AUTH_FAILED",
3
+ RATE_LIMITED = "RATE_LIMITED",
4
+ NOT_FOUND = "NOT_FOUND",
5
+ VALIDATION_ERROR = "VALIDATION_ERROR",
6
+ BACKEND_ERROR = "BACKEND_ERROR",
7
+ SYNC_CONFLICT = "SYNC_CONFLICT",
8
+ CONFIG_MISSING = "CONFIG_MISSING",
9
+ BACKEND_NOT_CONFIGURED = "BACKEND_NOT_CONFIGURED",
10
+ }
11
+
12
+ export interface BuchPilotError {
13
+ code: ErrorCode;
14
+ message: string;
15
+ details?: Record<string, unknown>;
16
+ retry_after_ms?: number;
17
+ }
18
+
19
+ export function createError(
20
+ code: ErrorCode,
21
+ message: string,
22
+ details?: Record<string, unknown>,
23
+ ): BuchPilotError {
24
+ return {
25
+ code,
26
+ message,
27
+ ...(details && { details }),
28
+ ...(code === ErrorCode.RATE_LIMITED && { retry_after_ms: 500 }),
29
+ };
30
+ }
31
+
32
+ export function isRetryable(error: BuchPilotError): boolean {
33
+ return error.code === ErrorCode.RATE_LIMITED || error.code === ErrorCode.BACKEND_ERROR;
34
+ }
package/src/index.ts ADDED
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import { loadConfig } from "./config.js";
6
+ import { ErrorCode, createError } from "./errors.js";
7
+ import { LexofficeBackend } from "./backends/lexoffice.js";
8
+ import type { IAccountingBackend } from "./backends/types.js";
9
+ import { registerContactTools } from "./tools/contacts.js";
10
+ import { registerInvoiceTools } from "./tools/invoices.js";
11
+ import { registerVoucherTools } from "./tools/vouchers.js";
12
+ import { registerQuotationTools } from "./tools/quotations.js";
13
+ import { registerSyncTools } from "./tools/sync.js";
14
+
15
+ // === Backend Registry ===
16
+
17
+ const backends = new Map<string, IAccountingBackend>();
18
+
19
+ function getBackend(name?: string): IAccountingBackend {
20
+ const config = loadConfig();
21
+ const backendName = name || config.default_backend;
22
+
23
+ // Lazy-initialize backends
24
+ if (!backends.has(backendName)) {
25
+ const backendConfig = config.backends[backendName];
26
+ if (!backendConfig) {
27
+ throw createError(
28
+ ErrorCode.BACKEND_NOT_CONFIGURED,
29
+ `Backend "${backendName}" is not configured. Available: ${Object.keys(config.backends).join(", ")}`,
30
+ );
31
+ }
32
+
33
+ switch (backendName) {
34
+ case "lexoffice":
35
+ backends.set(backendName, new LexofficeBackend(backendConfig.api_key));
36
+ break;
37
+ default:
38
+ throw createError(
39
+ ErrorCode.BACKEND_NOT_CONFIGURED,
40
+ `Unknown backend type: "${backendName}". Supported: lexoffice`,
41
+ );
42
+ }
43
+ }
44
+
45
+ return backends.get(backendName)!;
46
+ }
47
+
48
+ // === MCP Server ===
49
+
50
+ const server = new McpServer({
51
+ name: "buchpilot",
52
+ version: "0.1.1",
53
+ });
54
+
55
+ // Register all tools
56
+ registerContactTools(server, getBackend);
57
+ registerInvoiceTools(server, getBackend);
58
+ registerVoucherTools(server, getBackend);
59
+ registerQuotationTools(server, getBackend);
60
+ registerSyncTools(server, getBackend);
61
+
62
+ // === Start ===
63
+
64
+ async function main() {
65
+ const transport = new StdioServerTransport();
66
+ await server.connect(transport);
67
+ console.error("BuchPilot MCP Server running (stdio)");
68
+ }
69
+
70
+ main().catch((error) => {
71
+ console.error("Fatal error:", error);
72
+ process.exit(1);
73
+ });