einzly-mcp 0.1.0

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.
Files changed (3) hide show
  1. package/README.md +58 -0
  2. package/einzly-mcp.mjs +435 -0
  3. package/package.json +32 -0
package/README.md ADDED
@@ -0,0 +1,58 @@
1
+ # einzly MCP
2
+
3
+ Steuere deine [einzly](https://www.einzly.ch)-Buchhaltung über Claude, Cursor und andere
4
+ AI-Agenten — Rechnungen, Offerten, Ausgaben, Aufträge und Auswertungen per Sprache.
5
+ Du nutzt deine eigenen Claude-/AI-Tokens; einzly stellt nur die sichere Datenschnittstelle.
6
+
7
+ ## Einrichtung
8
+
9
+ 1. In einzly: **Einstellungen → Entwickler → API-Schlüssel erstellen** (Pro-Abo).
10
+ 2. MCP registrieren:
11
+
12
+ **Claude Code / Claude Desktop:**
13
+ ```
14
+ claude mcp add einzly --env EINZLY_API_KEY=ek_live_… -- npx -y einzly-mcp
15
+ ```
16
+
17
+ **Andere MCP-Clients (z. B. Cursor)** — in der MCP-Konfiguration:
18
+ ```json
19
+ {
20
+ "mcpServers": {
21
+ "einzly": {
22
+ "command": "npx",
23
+ "args": ["-y", "einzly-mcp"],
24
+ "env": { "EINZLY_API_KEY": "ek_live_…" }
25
+ }
26
+ }
27
+ }
28
+ ```
29
+ 3. Client neu starten.
30
+
31
+ ## Was du sagen kannst
32
+
33
+ - „Zeig mir meine offenen Rechnungen / meinen besten Kunden / Ausgaben mit MWST dieses Jahr."
34
+ - „Erstell eine Rechnung für [Kunde] über 2 Tage à 1'200."
35
+ - „Mach eine 30%-Anzahlung für Auftrag A-2026-005." → „Erstell die Schlussrechnung dazu."
36
+ - „Versende Rechnung R-2026-012." (kommt mit Bestätigungs-Rückfrage)
37
+
38
+ ## Sicherheit
39
+
40
+ - **Versand** (Rechnung/Offerte) erfordert immer eine **ausdrückliche Bestätigung** (`confirm=true`).
41
+ - Der Schlüssel hat vollen Zugriff auf dein Konto — wie ein Passwort behandeln, jederzeit im
42
+ Entwickler-Tab widerrufbar.
43
+
44
+ ## Eigener Code / eigene Tools
45
+
46
+ Der Schlüssel funktioniert auch direkt gegen die HTTP-API:
47
+
48
+ ```
49
+ curl -X POST https://www.einzly.ch/api/agent \
50
+ -H "Authorization: Bearer ek_live_…" \
51
+ -H "Content-Type: application/json" \
52
+ -d '{"tool":"get_dashboard_summary","input":{}}'
53
+ ```
54
+
55
+ ## Umgebungsvariablen
56
+
57
+ - `EINZLY_API_KEY` — dein API-Schlüssel (empfohlen).
58
+ - `EINZLY_BASE_URL` — optional, Default `https://www.einzly.ch`.
package/einzly-mcp.mjs ADDED
@@ -0,0 +1,435 @@
1
+ #!/usr/bin/env node
2
+ // einzly MCP-Server (read-only MVP).
3
+ // Teilt die Auth mit dem CLI: liest ~/.einzly/session.json (vorher `einzly login`).
4
+ // Exponiert Lese-Tools für Claude/Agents. WICHTIG: bei stdio nur auf stderr loggen,
5
+ // stdout gehört dem MCP-Protokoll.
6
+
7
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
+ import { z } from "zod";
10
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { join, dirname } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+
15
+ const __dirname = dirname(fileURLToPath(import.meta.url));
16
+ const SESSION_FILE = join(homedir(), ".einzly", "session.json");
17
+
18
+ function publicConfig() {
19
+ let url = process.env.NEXT_PUBLIC_SUPABASE_URL;
20
+ let anon = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
21
+ if ((!url || !anon) && existsSync(join(__dirname, "..", ".env.local"))) {
22
+ for (const line of readFileSync(join(__dirname, "..", ".env.local"), "utf8").split("\n")) {
23
+ const m = line.match(/^([A-Z_]+)=(.*)$/);
24
+ if (!m) continue;
25
+ const v = m[2].replace(/^["']|["']$/g, "");
26
+ if (m[1] === "NEXT_PUBLIC_SUPABASE_URL") url = url || v;
27
+ if (m[1] === "NEXT_PUBLIC_SUPABASE_ANON_KEY") anon = anon || v;
28
+ }
29
+ }
30
+ return { url, anon };
31
+ }
32
+
33
+ // Auth-Token holen: bevorzugt EINZLY_API_KEY (Entwickler-Tab in einzly), sonst CLI-Login-Session.
34
+ // Gibt { token } zurück — ALLE Tools laufen über /api/agent bzw. /api/send-* (kein direkter
35
+ // Supabase-Zugriff mehr), damit der API-Key (kein Supabase-JWT) voll funktioniert.
36
+ async function authed() {
37
+ const apiKey = process.env.EINZLY_API_KEY;
38
+ if (apiKey && apiKey.trim()) return { token: apiKey.trim() };
39
+
40
+ if (!existsSync(SESSION_FILE)) {
41
+ throw new Error("Kein Zugang. Setze EINZLY_API_KEY (Einstellungen → Entwickler in einzly) oder logge dich ein: `node cli/einzly.mjs login`.");
42
+ }
43
+ const sess = JSON.parse(readFileSync(SESSION_FILE, "utf8"));
44
+ const { url, anon } = publicConfig();
45
+ if (!url || !anon) throw new Error("Supabase-Konfiguration fehlt (NEXT_PUBLIC_SUPABASE_URL/ANON_KEY).");
46
+ // Nur der Login-Fallback braucht Supabase — im Key-Modus (EINZLY_API_KEY) wird das nie geladen.
47
+ const { createClient } = await import("@supabase/supabase-js");
48
+ const supabase = createClient(url, anon, { auth: { persistSession: false, autoRefreshToken: false } });
49
+ const { data, error } = await supabase.auth.setSession(sess);
50
+ if (error || !data.user) throw new Error("Session abgelaufen. Bitte neu einloggen: `node cli/einzly.mjs login`.");
51
+ // erneuerte Session zurückschreiben (Token-Rotation überlebt)
52
+ if (data.session) {
53
+ try {
54
+ writeFileSync(SESSION_FILE, JSON.stringify({ access_token: data.session.access_token, refresh_token: data.session.refresh_token }), { mode: 0o600 });
55
+ } catch { /* nicht kritisch */ }
56
+ }
57
+ return { token: data.session.access_token };
58
+ }
59
+
60
+ // Lese-Tools laufen über den Agent-Endpoint (eine Wahrheit, funktioniert mit Key ODER Login).
61
+ async function query(tool, input) {
62
+ const { token } = await authed();
63
+ return callAgent(token, tool, input || {});
64
+ }
65
+ const yearToRange = (year) => (year ? { von: `${year}-01-01`, bis: `${year}-12-31` } : {});
66
+
67
+ // Schreib-/Rechen-Aktionen laufen über den authentifizierten einzly-Agent-Endpoint
68
+ // (reused die App-Tool-Logik = eine Wahrheit, kein Doppel-Code).
69
+ // Default = Produktion. Für lokale Entwicklung: EINZLY_BASE_URL=http://localhost:3000 setzen.
70
+ const BASE_URL = process.env.EINZLY_BASE_URL || "https://www.einzly.ch";
71
+ async function callAgent(token, tool, input) {
72
+ const res = await fetch(`${BASE_URL}/api/agent`, {
73
+ method: "POST",
74
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
75
+ body: JSON.stringify({ tool, input }),
76
+ });
77
+ const json = await res.json().catch(() => ({}));
78
+ if (!res.ok) throw new Error(json.error || `Agent-Endpoint HTTP ${res.status}`);
79
+ return json.result;
80
+ }
81
+
82
+ async function callSendInvoice(token, payload) {
83
+ const res = await fetch(`${BASE_URL}/api/send-invoice`, {
84
+ method: "POST",
85
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
86
+ body: JSON.stringify(payload),
87
+ });
88
+ const json = await res.json().catch(() => ({}));
89
+ if (!res.ok) throw new Error(json.error || `send-invoice HTTP ${res.status}`);
90
+ return json;
91
+ }
92
+
93
+ const ok = (data) => ({ content: [{ type: "text", text: JSON.stringify(data, null, 2) }] });
94
+ const fail = (e) => ({ content: [{ type: "text", text: `Fehler: ${e.message || e}` }], isError: true });
95
+ const yearRange = (year) => [`${year}-01-01`, `${year}-12-31`];
96
+
97
+ // Rechnungs-Total — MUSS synchron bleiben mit src/lib/format.ts (positionTotal/calcSubtotal)
98
+ // + getTotal in src/app/(app)/rechnungen/page.tsx. Siehe memory einzly-invoice-calc-paths.
99
+ function positionTotal(menge, preis, rabattProzent, inklusivMenge) {
100
+ const eff = Math.max(0, (menge || 0) - (inklusivMenge || 0));
101
+ const brutto = eff * (preis || 0);
102
+ if (!rabattProzent || rabattProzent <= 0) return brutto;
103
+ return brutto * (1 - rabattProzent / 100);
104
+ }
105
+ function calcSubtotal(positionen) {
106
+ return (positionen || []).reduce((sum, pos, i) => {
107
+ if (pos.position_type === "prozentualer_rabatt") {
108
+ const prozent = pos.prozent_wert || 0;
109
+ const basis = positionen.slice(0, i).reduce((s, p) =>
110
+ p.position_type === "prozentualer_rabatt" ? s : s + positionTotal(p.menge, p.preis, p.rabatt_prozent, p.inklusiv_menge), 0);
111
+ return sum - Math.round(basis * prozent / 100 * 100) / 100;
112
+ }
113
+ return sum + positionTotal(pos.menge, pos.preis, pos.rabatt_prozent, pos.inklusiv_menge);
114
+ }, 0);
115
+ }
116
+ function invoiceTotal(r, mwstStatus) {
117
+ if (r.waehrung && r.waehrung !== "CHF" && r.betrag_chf != null) return r.betrag_chf;
118
+ const sub = calcSubtotal(r.rechnung_positionen || []);
119
+ const rate = mwstStatus === "keine" ? 0 : (r.mwst_typ && r.mwst_typ !== "normal") ? 0 : 8.1;
120
+ return Math.round(sub * (1 + rate / 100) * 100) / 100;
121
+ }
122
+ // Offerten speichern Positionen als JSONB-Spalte `positionen`.
123
+ function quoteTotal(o, mwstStatus) {
124
+ const sub = calcSubtotal(o.positionen || []);
125
+ const rate = mwstStatus === "keine" ? 0 : (o.mwst_typ && o.mwst_typ !== "normal") ? 0 : 8.1;
126
+ return Math.round(sub * (1 + rate / 100) * 100) / 100;
127
+ }
128
+
129
+ const server = new McpServer({ name: "einzly", version: "0.1.0" });
130
+
131
+ server.tool(
132
+ "list_expenses",
133
+ "Listet die Ausgaben (Belastungen) des eingeloggten einzly-Nutzers. Optional nach Jahr gefiltert.",
134
+ { year: z.string().optional(), limit: z.number().optional() },
135
+ async ({ year, limit }) => {
136
+ try { return ok(await query("query_ausgaben", { ...yearToRange(year), limit: limit ?? 50 })); }
137
+ catch (e) { return fail(e); }
138
+ }
139
+ );
140
+
141
+ server.tool(
142
+ "list_invoices",
143
+ "Listet die Rechnungen des Nutzers inkl. Betrag (CHF, inkl. MWST). Optional nach Status (entwurf, versendet, bezahlt, ueberfaellig). Zum offenen Gesamtbetrag: Status 'versendet'/'ueberfaellig' summieren.",
144
+ { status: z.string().optional(), limit: z.number().optional() },
145
+ async ({ status, limit }) => {
146
+ try { return ok(await query("query_rechnungen", { status, limit: limit ?? 50 })); }
147
+ catch (e) { return fail(e); }
148
+ }
149
+ );
150
+
151
+ server.tool(
152
+ "list_income",
153
+ "Listet die Einnahmen des Nutzers. Optional nach Jahr gefiltert.",
154
+ { year: z.string().optional(), limit: z.number().optional() },
155
+ async ({ year, limit }) => {
156
+ try { return ok(await query("query_einnahmen", { ...yearToRange(year), limit: limit ?? 50 })); }
157
+ catch (e) { return fail(e); }
158
+ }
159
+ );
160
+
161
+ server.tool(
162
+ "list_clients",
163
+ "Listet die Kunden des Nutzers (Firma, Kontakt, E-Mail, Telefon, Ort). Optionale Suche nach Name.",
164
+ { search: z.string().optional(), limit: z.number().optional() },
165
+ async ({ search, limit }) => {
166
+ try { return ok(await query("query_kunden", { name: search, limit: limit ?? 100 })); }
167
+ catch (e) { return fail(e); }
168
+ }
169
+ );
170
+
171
+ server.tool(
172
+ "client_revenue",
173
+ "Kundenliste mit Umsätzen: summiert die Rechnungs-Totals (inkl. MWST) pro Kunde, absteigend sortiert (oberster = bester Kunde). Zählt ausgestellte Rechnungen (ohne Entwürfe). Optional nach Jahr.",
174
+ { year: z.string().optional(), limit: z.number().optional() },
175
+ async ({ year, limit }) => {
176
+ try { return ok(await query("query_kunden_umsatz", { ...yearToRange(year), limit: limit ?? 50 })); }
177
+ catch (e) { return fail(e); }
178
+ }
179
+ );
180
+
181
+ // ---------- Schreib-Tools ----------
182
+
183
+ server.tool(
184
+ "create_client",
185
+ "Legt einen neuen Kunden an. firma_name = Firmenname ODER Vor-/Nachname.",
186
+ {
187
+ firma_name: z.string(),
188
+ kontakt_person: z.string().optional(),
189
+ email: z.string().optional(),
190
+ telefon: z.string().optional(),
191
+ adresse_strasse: z.string().optional(),
192
+ adresse_nr: z.string().optional(),
193
+ adresse_plz: z.string().optional(),
194
+ adresse_ort: z.string().optional(),
195
+ },
196
+ async (args) => {
197
+ try { const { token } = await authed(); return ok(await callAgent(token, "create_kunde", args)); }
198
+ catch (e) { return fail(e); }
199
+ }
200
+ );
201
+
202
+ server.tool(
203
+ "create_invoice_draft",
204
+ "Erstellt eine EIGENSTÄNDIGE Rechnung als ENTWURF (ohne Auftragsbezug, wird NICHT versendet). Kunde muss bereits existieren. ACHTUNG: Hat der Kunde einen offenen Auftrag, lieber create_invoice_from_order / create_partial_invoice / create_final_invoice nehmen — sonst Doppelverrechnung. Bei offenem Auftrag liefert das Tool eine Warnung statt zu erstellen; nur mit trotzdem_neu=true bewusst überschreiben.",
205
+ {
206
+ kunde: z.string(),
207
+ positionen: z.array(z.object({
208
+ beschreibung: z.string(),
209
+ preis: z.number(),
210
+ menge: z.number().optional(),
211
+ detail: z.string().optional(),
212
+ })),
213
+ zahlungsziel_tage: z.number().optional(),
214
+ notiz: z.string().optional(),
215
+ trotzdem_neu: z.boolean().optional(),
216
+ },
217
+ async (args) => {
218
+ try { const { token } = await authed(); return ok(await callAgent(token, "create_rechnung", args)); }
219
+ catch (e) { return fail(e); }
220
+ }
221
+ );
222
+
223
+ server.tool(
224
+ "create_invoice_from_order",
225
+ "Erstellt eine VOLLRECHNUNG (Entwurf) aus einem bestehenden Auftrag: übernimmt die Positionen, verknüpft Auftrag+Offerte und schliesst den Auftrag ab (verhindert Doppelverrechnung). Wenn bereits Teilrechnungen existieren → stattdessen create_final_invoice.",
226
+ {
227
+ auftrag: z.string().describe("Auftragsnummer oder eindeutiger Titel"),
228
+ zahlungsziel_tage: z.number().optional(),
229
+ notiz: z.string().optional(),
230
+ },
231
+ async (args) => {
232
+ try { const { token } = await authed(); return ok(await callAgent(token, "create_rechnung_aus_auftrag", args)); }
233
+ catch (e) { return fail(e); }
234
+ }
235
+ );
236
+
237
+ server.tool(
238
+ "create_partial_invoice",
239
+ "Erstellt eine TEILRECHNUNG/Anzahlung (Entwurf) zu einem Auftrag. Der Auftrag bleibt offen. Betrag als prozent (Anteil am Auftragstotal) ODER betrag (Bruttobetrag CHF inkl. MWST). Beispiel '30% Anzahlung' → prozent=30.",
240
+ {
241
+ auftrag: z.string().describe("Auftragsnummer oder eindeutiger Titel"),
242
+ prozent: z.number().optional().describe("Prozent des Auftrags-Nettototals, z.B. 30"),
243
+ betrag: z.number().optional().describe("Bruttobetrag dieser Teilrechnung in CHF (inkl. MWST)"),
244
+ zahlungsziel_tage: z.number().optional(),
245
+ notiz: z.string().optional(),
246
+ },
247
+ async (args) => {
248
+ try { const { token } = await authed(); return ok(await callAgent(token, "create_teilrechnung", args)); }
249
+ catch (e) { return fail(e); }
250
+ }
251
+ );
252
+
253
+ server.tool(
254
+ "create_final_invoice",
255
+ "Erstellt die SCHLUSSRECHNUNG (Entwurf) zu einem Auftrag: volle Positionen MINUS bereits gestellte Teilrechnungen (automatische Abzugs-Positionen) und schliesst den Auftrag ab.",
256
+ {
257
+ auftrag: z.string().describe("Auftragsnummer oder eindeutiger Titel"),
258
+ zahlungsziel_tage: z.number().optional(),
259
+ notiz: z.string().optional(),
260
+ },
261
+ async (args) => {
262
+ try { const { token } = await authed(); return ok(await callAgent(token, "create_schlussrechnung", args)); }
263
+ catch (e) { return fail(e); }
264
+ }
265
+ );
266
+
267
+ server.tool(
268
+ "create_recurring_invoice",
269
+ "Richtet eine wiederkehrende Rechnung ein (z.B. Retainer/Abo). Erstellt automatisch Entwürfe im Intervall.",
270
+ {
271
+ kunde: z.string(),
272
+ positionen: z.array(z.object({ beschreibung: z.string(), preis: z.number(), menge: z.number().optional() })),
273
+ intervall: z.string().optional(),
274
+ zahlungsziel: z.number().optional(),
275
+ notiz: z.string().optional(),
276
+ },
277
+ async (args) => {
278
+ try { const { token } = await authed(); return ok(await callAgent(token, "create_wiederkehrende_rechnung", args)); }
279
+ catch (e) { return fail(e); }
280
+ }
281
+ );
282
+
283
+ server.tool(
284
+ "create_income",
285
+ "Verbucht eine direkte Einnahme (ohne Rechnung) — z.B. bar, TWINT, PayPal.",
286
+ {
287
+ beschreibung: z.string(),
288
+ betrag: z.number(),
289
+ zahlungsart: z.string().optional(),
290
+ buchungstext: z.string().optional(),
291
+ datum: z.string().optional(),
292
+ },
293
+ async (args) => {
294
+ try { const { token } = await authed(); return ok(await callAgent(token, "create_einnahme", args)); }
295
+ catch (e) { return fail(e); }
296
+ }
297
+ );
298
+
299
+ server.tool(
300
+ "create_expense",
301
+ "Bucht eine einmalige Ausgabe. Hinweis: Der Beleg muss separat in einzly hochgeladen werden (eine Bankzeile/Buchung ist kein gültiger Leistungsbeleg).",
302
+ {
303
+ lieferant: z.string(),
304
+ betrag: z.number(),
305
+ kategorie: z.string().optional(),
306
+ datum: z.string().optional(),
307
+ bezahlt_mit: z.string().optional(),
308
+ buchungstext: z.string().optional(),
309
+ },
310
+ async (args) => {
311
+ try { const { token } = await authed(); return ok(await callAgent(token, "create_ausgabe", args)); }
312
+ catch (e) { return fail(e); }
313
+ }
314
+ );
315
+
316
+ server.tool(
317
+ "send_invoice",
318
+ "Versendet eine Rechnung per E-Mail an den Kunden. SICHERHEIT: Ohne confirm=true wird NUR eine Vorschau zurückgegeben (KEIN Versand). Zeige dem Nutzer Empfänger + Betrag, hole seine ausdrückliche Zustimmung, und rufe DANN erneut mit confirm=true auf. Setze confirm=true NIEMALS ohne explizite Bestätigung des Nutzers. (Pro-Abo erforderlich.)",
319
+ {
320
+ rechnung_nr: z.string(),
321
+ confirm: z.boolean().optional(),
322
+ email: z.string().optional(),
323
+ nachricht: z.string().optional(),
324
+ },
325
+ async ({ rechnung_nr, confirm, email, nachricht }) => {
326
+ try {
327
+ const { token } = await authed();
328
+ const r = await callAgent(token, "resolve_versand", { typ: "rechnung", nummer: rechnung_nr });
329
+ if (r?.error) return fail(new Error(r.error));
330
+ const recipient = (email || r.empfaenger || "").trim();
331
+ const betrag = `${r.waehrung || "CHF"} ${Number(r.betrag).toFixed(2)}`;
332
+ if (!recipient) {
333
+ return ok({ versand: false, grund: "Keine Empfänger-E-Mail hinterlegt. Bitte email angeben.", rechnung: rechnung_nr, kunde: r.kunde });
334
+ }
335
+ if (confirm !== true) {
336
+ return ok({
337
+ vorschau: true,
338
+ versand: false,
339
+ rechnung: rechnung_nr,
340
+ kunde: r.kunde,
341
+ empfaenger: recipient,
342
+ betrag,
343
+ status: r.status,
344
+ hinweis: "Versand NICHT ausgeführt. Bestätige Empfänger und Betrag mit dem Nutzer, dann send_invoice erneut mit confirm=true aufrufen.",
345
+ });
346
+ }
347
+ await callSendInvoice(token, { rechnung_id: r.id, email: recipient, nachricht: nachricht || undefined });
348
+ return ok({ versand: true, rechnung: rechnung_nr, empfaenger: recipient, betrag });
349
+ } catch (e) { return fail(e); }
350
+ }
351
+ );
352
+
353
+ server.tool(
354
+ "list_quotes",
355
+ "Listet Offerten inkl. Betrag (CHF). Optional nach Status (entwurf, versendet, angenommen, abgelehnt, abgelaufen).",
356
+ { status: z.string().optional(), limit: z.number().optional() },
357
+ async ({ status, limit }) => {
358
+ try { return ok(await query("query_offerten", { status, limit: limit ?? 50 })); }
359
+ catch (e) { return fail(e); }
360
+ }
361
+ );
362
+
363
+ server.tool(
364
+ "create_quote_draft",
365
+ "Erstellt eine Offerte als ENTWURF. Kunde muss bereits existieren (sonst zuerst create_client).",
366
+ {
367
+ kunde: z.string(),
368
+ positionen: z.array(z.object({ beschreibung: z.string(), preis: z.number(), menge: z.number().optional(), detail: z.string().optional() })),
369
+ gueltig_tage: z.number().optional(),
370
+ notiz: z.string().optional(),
371
+ },
372
+ async (args) => {
373
+ try { const { token } = await authed(); return ok(await callAgent(token, "create_offerte", args)); }
374
+ catch (e) { return fail(e); }
375
+ }
376
+ );
377
+
378
+ server.tool(
379
+ "send_quote",
380
+ "Versendet eine Offerte per E-Mail. SICHERHEIT: ohne confirm=true nur Vorschau (kein Versand). Empfänger + Betrag dem Nutzer zeigen, ausdrückliche Zustimmung holen, DANN confirm=true. (Pro-Abo erforderlich.)",
381
+ {
382
+ offerten_nr: z.string(),
383
+ confirm: z.boolean().optional(),
384
+ email: z.string().optional(),
385
+ nachricht: z.string().optional(),
386
+ },
387
+ async ({ offerten_nr, confirm, email, nachricht }) => {
388
+ try {
389
+ const { token } = await authed();
390
+ const o = await callAgent(token, "resolve_versand", { typ: "offerte", nummer: offerten_nr });
391
+ if (o?.error) return fail(new Error(o.error));
392
+ const recipient = (email || o.empfaenger || "").trim();
393
+ const betrag = `${o.waehrung || "CHF"} ${Number(o.betrag).toFixed(2)}`;
394
+ if (!recipient) {
395
+ return ok({ versand: false, grund: "Keine Empfänger-E-Mail hinterlegt. Bitte email angeben.", offerte: offerten_nr, kunde: o.kunde });
396
+ }
397
+ if (confirm !== true) {
398
+ return ok({ vorschau: true, versand: false, offerte: offerten_nr, kunde: o.kunde, empfaenger: recipient, betrag, status: o.status,
399
+ hinweis: "Versand NICHT ausgeführt. Empfänger und Betrag mit dem Nutzer bestätigen, dann send_quote erneut mit confirm=true aufrufen." });
400
+ }
401
+ const res = await fetch(`${BASE_URL}/api/send-offerte`, {
402
+ method: "POST",
403
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
404
+ body: JSON.stringify({ offerte_id: o.id, email: recipient, nachricht: nachricht || undefined }),
405
+ });
406
+ const j = await res.json().catch(() => ({}));
407
+ if (!res.ok) throw new Error(j.error || `send-offerte HTTP ${res.status}`);
408
+ return ok({ versand: true, offerte: offerten_nr, empfaenger: recipient, betrag });
409
+ } catch (e) { return fail(e); }
410
+ }
411
+ );
412
+
413
+ server.tool(
414
+ "list_orders",
415
+ "Listet Aufträge (auftrags_nr, Titel, Status, Start-/Abschlussdatum, Kunde).",
416
+ { limit: z.number().optional() },
417
+ async ({ limit }) => {
418
+ try { const { token } = await authed(); return ok(await callAgent(token, "query_auftraege", { limit: limit ?? 50 })); }
419
+ catch (e) { return fail(e); }
420
+ }
421
+ );
422
+
423
+ server.tool(
424
+ "dashboard_summary",
425
+ "Liefert eine Finanz-Zusammenfassung (Total Einnahmen, Total Ausgaben, Saldo) für ein Jahr (Default: laufendes Jahr).",
426
+ { year: z.string().optional() },
427
+ async ({ year }) => {
428
+ try { return ok(await query("get_dashboard_summary", { jahr: year ? Number(year) : new Date().getFullYear() })); }
429
+ catch (e) { return fail(e); }
430
+ }
431
+ );
432
+
433
+ const transport = new StdioServerTransport();
434
+ await server.connect(transport);
435
+ console.error("einzly MCP-Server bereit (read-only).");
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "einzly-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP-Server für einzly — steuere deine Schweizer Buchhaltung (Rechnungen, Offerten, Ausgaben, Aufträge, Auswertungen) über Claude, Cursor und andere AI-Agenten.",
5
+ "type": "module",
6
+ "bin": {
7
+ "einzly-mcp": "einzly-mcp.mjs"
8
+ },
9
+ "files": [
10
+ "einzly-mcp.mjs",
11
+ "README.md"
12
+ ],
13
+ "engines": {
14
+ "node": ">=18"
15
+ },
16
+ "license": "UNLICENSED",
17
+ "homepage": "https://www.einzly.ch",
18
+ "keywords": [
19
+ "einzly",
20
+ "mcp",
21
+ "model-context-protocol",
22
+ "buchhaltung",
23
+ "claude"
24
+ ],
25
+ "dependencies": {
26
+ "@modelcontextprotocol/sdk": "^1.29.0",
27
+ "zod": "^4.4.3"
28
+ },
29
+ "optionalDependencies": {
30
+ "@supabase/supabase-js": "^2.97.0"
31
+ }
32
+ }