lxw-cli 0.1.3__tar.gz → 0.4.0__tar.gz

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 (57) hide show
  1. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/PKG-INFO +65 -4
  2. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/README.md +63 -3
  3. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/pyproject.toml +3 -1
  4. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/core/client.py +4 -2
  5. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/core/services.py +347 -0
  6. lxw_cli-0.4.0/src/lxw_cli/feature_request.py +57 -0
  7. lxw_cli-0.4.0/src/lxw_cli/mcp_auth.py +586 -0
  8. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/mcp_server.py +223 -20
  9. lxw_cli-0.4.0/tests/test_feature_request.py +32 -0
  10. lxw_cli-0.4.0/tests/test_mcp_auth.py +358 -0
  11. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/tests/test_mcp_server.py +18 -1
  12. lxw_cli-0.4.0/tests/test_services.py +498 -0
  13. lxw_cli-0.1.3/tests/test_services.py +0 -257
  14. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/.env.example +0 -0
  15. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/.gitattributes +0 -0
  16. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/.github/workflows/publish.yml +0 -0
  17. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/.github/workflows/test.yml +0 -0
  18. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/.gitignore +0 -0
  19. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/LICENSE +0 -0
  20. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/docs/tui-screenshot.svg +0 -0
  21. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/__init__.py +0 -0
  22. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/__main__.py +0 -0
  23. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/cli.py +0 -0
  24. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/commands/__init__.py +0 -0
  25. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/commands/_common.py +0 -0
  26. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/commands/articles.py +0 -0
  27. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/commands/contacts.py +0 -0
  28. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/commands/delivery_notes.py +0 -0
  29. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/commands/invoices.py +0 -0
  30. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/commands/mcp.py +0 -0
  31. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/commands/orders.py +0 -0
  32. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/commands/quotations.py +0 -0
  33. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/commands/vouchers.py +0 -0
  34. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/config.py +0 -0
  35. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/core/__init__.py +0 -0
  36. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/core/constants.py +0 -0
  37. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/core/errors.py +0 -0
  38. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/core/models.py +0 -0
  39. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/output.py +0 -0
  40. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/tui/__init__.py +0 -0
  41. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/tui/app.py +0 -0
  42. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/tui/detail.py +0 -0
  43. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/tui/entities.py +0 -0
  44. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/tui/order_form.py +0 -0
  45. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/tests/__init__.py +0 -0
  46. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/tests/conftest.py +0 -0
  47. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/tests/snapshots/contacts_flat.txt +0 -0
  48. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/tests/snapshots/contacts_grouped.txt +0 -0
  49. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/tests/snapshots/invoices_list.txt +0 -0
  50. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/tests/test_cli.py +0 -0
  51. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/tests/test_cli_snapshots.py +0 -0
  52. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/tests/test_client.py +0 -0
  53. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/tests/test_config.py +0 -0
  54. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/tests/test_output.py +0 -0
  55. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/tests/test_resolve.py +0 -0
  56. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/tests/test_tui.py +0 -0
  57. {lxw_cli-0.1.3 → lxw_cli-0.4.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lxw-cli
3
- Version: 0.1.3
3
+ Version: 0.4.0
4
4
  Summary: Inoffizielles CLI/TUI/MCP-Tool für die Lexware Office API (vormals lexoffice)
5
5
  Project-URL: Homepage, https://github.com/OEM-Mrks/lxw-cli
6
6
  Project-URL: Documentation, https://developers.lexware.io/
@@ -17,6 +17,7 @@ Classifier: Programming Language :: Python :: 3.11
17
17
  Classifier: Programming Language :: Python :: 3.12
18
18
  Classifier: Topic :: Office/Business :: Financial :: Accounting
19
19
  Requires-Python: >=3.11
20
+ Requires-Dist: cryptography>=42
20
21
  Requires-Dist: fastmcp>=2.10
21
22
  Requires-Dist: httpx>=0.27
22
23
  Requires-Dist: pydantic>=2.6
@@ -97,10 +98,11 @@ lxw --version # Kontrolle
97
98
  ```
98
99
 
99
100
  Sieht uv eine frisch veröffentlichte Version noch nicht (das PyPI-CDN braucht
100
- nach einem Release ein paar Minuten), erzwingt `--refresh` den Index-Abgleich:
101
+ nach einem Release ein paar Minuten) oder ist das Tool auf eine exakte Version
102
+ gepinnt, hilft ein Neu-Install auf `latest`:
101
103
 
102
104
  ```bash
103
- uv tool upgrade --refresh lxw-cli
105
+ uv tool install --force lxw-cli@latest
104
106
  ```
105
107
 
106
108
  Der MCP-Server für Claude nutzt das Update automatisch mit — bereits laufende
@@ -405,11 +407,21 @@ Claude erkennt die Tools automatisch und ruft sie auf.
405
407
 
406
408
  ### Verfügbare MCP-Tools
407
409
 
408
- 26 Tools mit identischem Verhalten wie die CLI-Befehle:
410
+ 31 Tools mit identischem Verhalten wie die CLI-Befehle:
409
411
 
412
+ - **Info**: `version` (meldet die laufende Version + Build-Zeitstempel — nützlich, wenn eine Client-UI wie ChatGPT eine gecachte/alte Version anzeigt)
413
+ - **Belegkette (weiterführen)**: `continue_document` führt einen Beleg in den Folgebeleg fort und hält beide verknüpft (`precedingSalesVoucherId`). Der Quelltyp wird automatisch erkannt (Belegnummer oder id). Unterstützte Wege (wie in Lexware):
414
+ - **Angebot** → Auftrag · Lieferschein · Rechnung
415
+ - **Auftrag** → Lieferschein · Rechnung
416
+ - **Lieferschein** → Rechnung
417
+ - **Rechnung** → Lieferschein · Rechnungskorrektur
418
+
419
+ Inhalte (Kunde, Positionen, Texte) werden übernommen, der Folgebeleg entsteht als Entwurf. Für Rechnung/Rechnungskorrektur muss der Ausgangsbeleg **festgeschrieben** sein (sonst klare Meldung statt HTTP 406). **Abschlagsrechnung** und **Serienrechnung** sind über die Lexware-API nicht erstellbar — das meldet das Tool klar.
410
420
  - **Lesen**: `profile`, `list_invoices`, `get_invoice`, `download_invoice_pdf`, `list_contacts`, `get_contact`, `list_vouchers`, `get_voucher`, `list_articles`, `get_article`, `list_quotations`, `get_quotation`, `download_quotation_pdf`, `list_order_confirmations`, `get_order_confirmation`, `download_order_confirmation_pdf`, `list_delivery_notes`, `get_delivery_note`, `download_delivery_note_pdf`
411
421
  - **Belege als Draft anlegen**: `create_invoice_draft`, `create_voucher_draft`, `create_quotation_draft`, `create_order_confirmation_draft`, `create_delivery_note_draft`
412
422
  - **Stammdaten anlegen** (kein Draft): `create_contact`, `create_article`
423
+ - **Stammdaten bearbeiten** (Teil-Update mit Auto-Merge + automatischem `version`-Handling): `update_contact`, `update_article`. Es werden nur die übergebenen Felder geändert; verschachtelte Objekte werden feldweise zusammengeführt, Listen komplett ersetzt. (Kontakt-`archived` ist in der Lexware-API read-only und kann nicht per API gesetzt werden. Firmen-Kontakte mit **mehr als einem Ansprechpartner** lassen sich per Lexware-API grundsätzlich nicht aktualisieren — `update_contact` meldet das klar, statt eines rohen HTTP 406.)
424
+ - **Funktionswunsch melden**: `request_feature` erstellt einen **unverbindlichen** Feature-Wunsch als fertigen Text (`subject`/`body`) samt Anbieter-Adresse (`to` = david@oemedia.de). Der Server **versendet nichts** — der Nutzer kopiert den Text und schickt ihn selbst per E-Mail. Dies ist ein Endkundenprodukt: der Assistent baut keine Funktionen selbst, sondern bietet bei fehlenden Funktionen nur diesen Weg an.
413
425
 
414
426
  Alle document-Tools (`get_*`, `download_*_pdf`) akzeptieren UUID **oder** Belegnummer (z.B. `FB2600682`). PDFs landen in `~/Downloads/lexware/`.
415
427
 
@@ -431,6 +443,55 @@ lxw mcp uninstall-desktop # aus Claude Desktop entfernen
431
443
  lxw mcp serve # läuft direkt — wird von Claude intern aufgerufen
432
444
  ```
433
445
 
446
+ ### Multi-User-Server (HTTP) — ein Server, jeder mit eigenem Key
447
+
448
+ Neben dem lokalen stdio-Modus gibt es `lxw-mcp-http`: einen zentralen
449
+ MCP-Server für mehrere Nutzer, bei dem **jeder seinen eigenen Lexware-API-Key
450
+ mitbringt**. Der Server speichert keine Keys — er ist ein zustandsloser Proxy.
451
+
452
+ ```bash
453
+ LXW_MCP_PUBLIC_URL=https://mcp.example.com \
454
+ LXW_MCP_SECRET='ein-langes-zufaelliges-secret' \
455
+ lxw-mcp-http # lauscht auf 127.0.0.1:8788 (LXW_MCP_HOST/LXW_MCP_PORT)
456
+ ```
457
+
458
+ Zwei Wege, den eigenen Key mitzubringen:
459
+
460
+ 1. **Direkt als Bearer-Header** (Claude Code, Cursor, Skripte — alles, was
461
+ eigene Header kann). Der Key bleibt in der lokalen Client-Config:
462
+
463
+ ```bash
464
+ claude mcp add lexware --transport http https://mcp.example.com/mcp \
465
+ --header "Authorization: Bearer ${LEXWARE_API_KEY}"
466
+ ```
467
+
468
+ 2. **OAuth** (claude.ai, ChatGPT — Clients ohne Header-Support): Beim
469
+ Verbinden öffnet sich eine Consent-Seite des Servers, auf der man einmalig
470
+ seinen Lexware-Key eingibt. Der Server prüft ihn gegen die Lexware-API und
471
+ stellt ein Zugriffstoken aus, in dem der Key **verschlüsselt eingebettet**
472
+ ist (Fernet, `LXW_MCP_SECRET`). Das Token liegt beim Client auf dem Gerät
473
+ des Nutzers; der Server entschlüsselt es nur pro Request und persistiert
474
+ ausschließlich die OAuth-Client-Registrierungen (`LXW_MCP_DATA_DIR`,
475
+ Standard `~/.config/lexware/mcp/`).
476
+
477
+ Hinweise für den Betrieb:
478
+
479
+ - `LXW_MCP_SECRET` **fest setzen** — ohne festes Secret werden alle Tokens
480
+ bei jedem Neustart ungültig. Rotieren des Secrets invalidiert bewusst alle
481
+ ausgestellten Tokens.
482
+ - Vor den Server gehört ein HTTPS-Reverse-Proxy (Caddy, nginx, Cloudflare
483
+ Tunnel, `tailscale serve`, …); `LXW_MCP_PUBLIC_URL` ist die von außen
484
+ sichtbare Basis-URL (sie steht in den OAuth-Metadaten).
485
+ - PDF-Downloads liefern über HTTP das PDF als Binärinhalt zurück (statt eines
486
+ Pfads auf der Server-Platte wie im stdio-Modus).
487
+ - Tokens laufen nach 24 h ab; Clients erneuern sie automatisch per
488
+ Refresh-Token. Ein geleaktes Token wird wirkungslos, sobald der zugehörige
489
+ Lexware-Key unter <https://app.lexware.de/addons/public-api> widerrufen wird.
490
+
491
+ `request_feature` braucht keine Konfiguration: es liefert nur einen fertigen
492
+ Text + die Empfänger-Adresse (`david@oemedia.de`) zurück, den der Nutzer selbst
493
+ per E-Mail sendet. Der Server verschickt nichts.
494
+
434
495
  ## Out of Scope (v1)
435
496
 
436
497
  Bewusst nicht enthalten:
@@ -62,10 +62,11 @@ lxw --version # Kontrolle
62
62
  ```
63
63
 
64
64
  Sieht uv eine frisch veröffentlichte Version noch nicht (das PyPI-CDN braucht
65
- nach einem Release ein paar Minuten), erzwingt `--refresh` den Index-Abgleich:
65
+ nach einem Release ein paar Minuten) oder ist das Tool auf eine exakte Version
66
+ gepinnt, hilft ein Neu-Install auf `latest`:
66
67
 
67
68
  ```bash
68
- uv tool upgrade --refresh lxw-cli
69
+ uv tool install --force lxw-cli@latest
69
70
  ```
70
71
 
71
72
  Der MCP-Server für Claude nutzt das Update automatisch mit — bereits laufende
@@ -370,11 +371,21 @@ Claude erkennt die Tools automatisch und ruft sie auf.
370
371
 
371
372
  ### Verfügbare MCP-Tools
372
373
 
373
- 26 Tools mit identischem Verhalten wie die CLI-Befehle:
374
+ 31 Tools mit identischem Verhalten wie die CLI-Befehle:
374
375
 
376
+ - **Info**: `version` (meldet die laufende Version + Build-Zeitstempel — nützlich, wenn eine Client-UI wie ChatGPT eine gecachte/alte Version anzeigt)
377
+ - **Belegkette (weiterführen)**: `continue_document` führt einen Beleg in den Folgebeleg fort und hält beide verknüpft (`precedingSalesVoucherId`). Der Quelltyp wird automatisch erkannt (Belegnummer oder id). Unterstützte Wege (wie in Lexware):
378
+ - **Angebot** → Auftrag · Lieferschein · Rechnung
379
+ - **Auftrag** → Lieferschein · Rechnung
380
+ - **Lieferschein** → Rechnung
381
+ - **Rechnung** → Lieferschein · Rechnungskorrektur
382
+
383
+ Inhalte (Kunde, Positionen, Texte) werden übernommen, der Folgebeleg entsteht als Entwurf. Für Rechnung/Rechnungskorrektur muss der Ausgangsbeleg **festgeschrieben** sein (sonst klare Meldung statt HTTP 406). **Abschlagsrechnung** und **Serienrechnung** sind über die Lexware-API nicht erstellbar — das meldet das Tool klar.
375
384
  - **Lesen**: `profile`, `list_invoices`, `get_invoice`, `download_invoice_pdf`, `list_contacts`, `get_contact`, `list_vouchers`, `get_voucher`, `list_articles`, `get_article`, `list_quotations`, `get_quotation`, `download_quotation_pdf`, `list_order_confirmations`, `get_order_confirmation`, `download_order_confirmation_pdf`, `list_delivery_notes`, `get_delivery_note`, `download_delivery_note_pdf`
376
385
  - **Belege als Draft anlegen**: `create_invoice_draft`, `create_voucher_draft`, `create_quotation_draft`, `create_order_confirmation_draft`, `create_delivery_note_draft`
377
386
  - **Stammdaten anlegen** (kein Draft): `create_contact`, `create_article`
387
+ - **Stammdaten bearbeiten** (Teil-Update mit Auto-Merge + automatischem `version`-Handling): `update_contact`, `update_article`. Es werden nur die übergebenen Felder geändert; verschachtelte Objekte werden feldweise zusammengeführt, Listen komplett ersetzt. (Kontakt-`archived` ist in der Lexware-API read-only und kann nicht per API gesetzt werden. Firmen-Kontakte mit **mehr als einem Ansprechpartner** lassen sich per Lexware-API grundsätzlich nicht aktualisieren — `update_contact` meldet das klar, statt eines rohen HTTP 406.)
388
+ - **Funktionswunsch melden**: `request_feature` erstellt einen **unverbindlichen** Feature-Wunsch als fertigen Text (`subject`/`body`) samt Anbieter-Adresse (`to` = david@oemedia.de). Der Server **versendet nichts** — der Nutzer kopiert den Text und schickt ihn selbst per E-Mail. Dies ist ein Endkundenprodukt: der Assistent baut keine Funktionen selbst, sondern bietet bei fehlenden Funktionen nur diesen Weg an.
378
389
 
379
390
  Alle document-Tools (`get_*`, `download_*_pdf`) akzeptieren UUID **oder** Belegnummer (z.B. `FB2600682`). PDFs landen in `~/Downloads/lexware/`.
380
391
 
@@ -396,6 +407,55 @@ lxw mcp uninstall-desktop # aus Claude Desktop entfernen
396
407
  lxw mcp serve # läuft direkt — wird von Claude intern aufgerufen
397
408
  ```
398
409
 
410
+ ### Multi-User-Server (HTTP) — ein Server, jeder mit eigenem Key
411
+
412
+ Neben dem lokalen stdio-Modus gibt es `lxw-mcp-http`: einen zentralen
413
+ MCP-Server für mehrere Nutzer, bei dem **jeder seinen eigenen Lexware-API-Key
414
+ mitbringt**. Der Server speichert keine Keys — er ist ein zustandsloser Proxy.
415
+
416
+ ```bash
417
+ LXW_MCP_PUBLIC_URL=https://mcp.example.com \
418
+ LXW_MCP_SECRET='ein-langes-zufaelliges-secret' \
419
+ lxw-mcp-http # lauscht auf 127.0.0.1:8788 (LXW_MCP_HOST/LXW_MCP_PORT)
420
+ ```
421
+
422
+ Zwei Wege, den eigenen Key mitzubringen:
423
+
424
+ 1. **Direkt als Bearer-Header** (Claude Code, Cursor, Skripte — alles, was
425
+ eigene Header kann). Der Key bleibt in der lokalen Client-Config:
426
+
427
+ ```bash
428
+ claude mcp add lexware --transport http https://mcp.example.com/mcp \
429
+ --header "Authorization: Bearer ${LEXWARE_API_KEY}"
430
+ ```
431
+
432
+ 2. **OAuth** (claude.ai, ChatGPT — Clients ohne Header-Support): Beim
433
+ Verbinden öffnet sich eine Consent-Seite des Servers, auf der man einmalig
434
+ seinen Lexware-Key eingibt. Der Server prüft ihn gegen die Lexware-API und
435
+ stellt ein Zugriffstoken aus, in dem der Key **verschlüsselt eingebettet**
436
+ ist (Fernet, `LXW_MCP_SECRET`). Das Token liegt beim Client auf dem Gerät
437
+ des Nutzers; der Server entschlüsselt es nur pro Request und persistiert
438
+ ausschließlich die OAuth-Client-Registrierungen (`LXW_MCP_DATA_DIR`,
439
+ Standard `~/.config/lexware/mcp/`).
440
+
441
+ Hinweise für den Betrieb:
442
+
443
+ - `LXW_MCP_SECRET` **fest setzen** — ohne festes Secret werden alle Tokens
444
+ bei jedem Neustart ungültig. Rotieren des Secrets invalidiert bewusst alle
445
+ ausgestellten Tokens.
446
+ - Vor den Server gehört ein HTTPS-Reverse-Proxy (Caddy, nginx, Cloudflare
447
+ Tunnel, `tailscale serve`, …); `LXW_MCP_PUBLIC_URL` ist die von außen
448
+ sichtbare Basis-URL (sie steht in den OAuth-Metadaten).
449
+ - PDF-Downloads liefern über HTTP das PDF als Binärinhalt zurück (statt eines
450
+ Pfads auf der Server-Platte wie im stdio-Modus).
451
+ - Tokens laufen nach 24 h ab; Clients erneuern sie automatisch per
452
+ Refresh-Token. Ein geleaktes Token wird wirkungslos, sobald der zugehörige
453
+ Lexware-Key unter <https://app.lexware.de/addons/public-api> widerrufen wird.
454
+
455
+ `request_feature` braucht keine Konfiguration: es liefert nur einen fertigen
456
+ Text + die Empfänger-Adresse (`david@oemedia.de`) zurück, den der Nutzer selbst
457
+ per E-Mail sendet. Der Server verschickt nichts.
458
+
399
459
  ## Out of Scope (v1)
400
460
 
401
461
  Bewusst nicht enthalten:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "lxw-cli"
7
- version = "0.1.3"
7
+ version = "0.4.0"
8
8
  description = "Inoffizielles CLI/TUI/MCP-Tool für die Lexware Office API (vormals lexoffice)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -30,6 +30,7 @@ dependencies = [
30
30
  "python-dotenv>=1.0",
31
31
  "fastmcp>=2.10",
32
32
  "textual>=0.58",
33
+ "cryptography>=42",
33
34
  ]
34
35
 
35
36
  [project.optional-dependencies]
@@ -44,6 +45,7 @@ dev = [
44
45
  [project.scripts]
45
46
  lxw = "lxw_cli.cli:_run"
46
47
  lxw-mcp = "lxw_cli.mcp_server:run"
48
+ lxw-mcp-http = "lxw_cli.mcp_server:run_http"
47
49
  lxw-tui = "lxw_cli.tui.app:run"
48
50
 
49
51
  [project.urls]
@@ -125,8 +125,10 @@ class LexwareClient:
125
125
  def get(self, path: str, params: dict[str, Any] | None = None) -> Any:
126
126
  return self._request("GET", path, params=params).json()
127
127
 
128
- def post(self, path: str, json_body: Any) -> Any:
129
- return self._request("POST", path, json=json_body).json()
128
+ def post(
129
+ self, path: str, json_body: Any, params: dict[str, Any] | None = None
130
+ ) -> Any:
131
+ return self._request("POST", path, params=params, json=json_body).json()
130
132
 
131
133
  def put(self, path: str, json_body: Any) -> Any:
132
134
  return self._request("PUT", path, json=json_body).json()
@@ -500,6 +500,263 @@ def create_delivery_note(
500
500
  return client.post("/v1/delivery-notes", body)
501
501
 
502
502
 
503
+ # -- Belegkette: einen Beleg in den Folgebeleg fortführen (pursue) -----------
504
+
505
+ # Human labels + the resource endpoint per document type.
506
+ _DOC_META: dict[str, dict[str, str]] = {
507
+ "quotation": {"label": "Angebot", "path": "/v1/quotations/{id}"},
508
+ "orderconfirmation": {"label": "Auftrag", "path": "/v1/order-confirmations/{id}"},
509
+ "deliverynote": {"label": "Lieferschein", "path": "/v1/delivery-notes/{id}"},
510
+ "invoice": {"label": "Rechnung", "path": "/v1/invoices/{id}"},
511
+ "creditnote": {"label": "Rechnungskorrektur", "path": "/v1/credit-notes/{id}"},
512
+ }
513
+
514
+ # The follow-up transitions Lexware's API supports (mirrors the web app's
515
+ # "Weiterführen" menu). Nested: source type -> target type -> create endpoint.
516
+ # Abschlagsrechnung (down payment) and Serienrechnung are intentionally absent —
517
+ # the API cannot create those (down payment invoices are read-only).
518
+ _PURSUE_MATRIX: dict[str, dict[str, str]] = {
519
+ "quotation": {
520
+ "orderconfirmation": "/v1/order-confirmations",
521
+ "deliverynote": "/v1/delivery-notes",
522
+ "invoice": "/v1/invoices",
523
+ },
524
+ "orderconfirmation": {
525
+ "deliverynote": "/v1/delivery-notes",
526
+ "invoice": "/v1/invoices",
527
+ },
528
+ "deliverynote": {
529
+ "invoice": "/v1/invoices",
530
+ },
531
+ "invoice": {
532
+ "deliverynote": "/v1/delivery-notes",
533
+ "creditnote": "/v1/credit-notes",
534
+ },
535
+ }
536
+
537
+ # Map human/German synonyms (and API type strings) to a normalized target type.
538
+ _TARGET_ALIASES = {
539
+ "auftrag": "orderconfirmation",
540
+ "auftragsbestätigung": "orderconfirmation",
541
+ "auftragsbestaetigung": "orderconfirmation",
542
+ "orderconfirmation": "orderconfirmation",
543
+ "order_confirmation": "orderconfirmation",
544
+ "order-confirmation": "orderconfirmation",
545
+ "lieferschein": "deliverynote",
546
+ "deliverynote": "deliverynote",
547
+ "delivery_note": "deliverynote",
548
+ "rechnung": "invoice",
549
+ "invoice": "invoice",
550
+ "rechnungskorrektur": "creditnote",
551
+ "gutschrift": "creditnote",
552
+ "korrektur": "creditnote",
553
+ "creditnote": "creditnote",
554
+ "credit_note": "creditnote",
555
+ }
556
+
557
+ # Targets a user may ask for that the API cannot create (only the web app can).
558
+ _TARGET_UNSUPPORTED = {
559
+ "abschlagsrechnung": "Abschlagsrechnung",
560
+ "downpaymentinvoice": "Abschlagsrechnung",
561
+ "down_payment_invoice": "Abschlagsrechnung",
562
+ "serienrechnung": "Serienrechnung",
563
+ "recurring": "Serienrechnung",
564
+ }
565
+
566
+ # voucherlist reports finalized invoices as "salesinvoice"; normalize to our key.
567
+ _SOURCE_TYPE_NORMALIZE = {"salesinvoice": "invoice"}
568
+
569
+ # Server-managed / computed fields, plus type-specific fields that must not be
570
+ # carried into the follow-up (e.g. a quotation's expirationDate has no place on
571
+ # an order confirmation or invoice).
572
+ _PURSUE_DROP_DOC = {
573
+ "id",
574
+ "organizationId",
575
+ "version",
576
+ "voucherNumber",
577
+ "voucherStatus",
578
+ "createdDate",
579
+ "updatedDate",
580
+ "files",
581
+ "relatedVouchers",
582
+ "archived",
583
+ "taxAmounts",
584
+ "expirationDate",
585
+ "electronicDocumentProfile",
586
+ }
587
+ _PURSUE_DROP_LINEITEM = {"id", "lineItemAmount"}
588
+
589
+
590
+ def continue_document(
591
+ client: LexwareClient, identifier: str, target: str
592
+ ) -> dict[str, Any]:
593
+ """Continue a sales voucher into its follow-up, preserving the chain.
594
+
595
+ Detects the source document's type from `identifier` (UUID or voucher
596
+ number), copies its content into a fresh draft of `target`, and POSTs it
597
+ with ``precedingSalesVoucherId`` so Lexware links both in the Belegkette.
598
+ Supported transitions mirror the Lexware web app (Angebot→Auftrag/
599
+ Lieferschein/Rechnung, Auftrag→Lieferschein/Rechnung, Lieferschein→Rechnung,
600
+ Rechnung→Lieferschein/Rechnungskorrektur). Invalid or unsupported steps are
601
+ reported clearly instead of as a raw HTTP 406.
602
+ """
603
+ raw = target.strip().lower()
604
+ if raw in _TARGET_UNSUPPORTED:
605
+ name = _TARGET_UNSUPPORTED[raw]
606
+ raise LexwareError(
607
+ f"{name} kann über die Schnittstelle nicht erstellt werden — das geht "
608
+ "nur direkt in Lexware."
609
+ )
610
+ target_type = _TARGET_ALIASES.get(raw)
611
+ if target_type is None:
612
+ raise LexwareError(
613
+ f"Unbekanntes Ziel '{target}'. Möglich sind: Auftrag, Lieferschein, "
614
+ "Rechnung oder Rechnungskorrektur."
615
+ )
616
+
617
+ source_type, source_id, source = _resolve_source(client, identifier)
618
+ allowed = _PURSUE_MATRIX.get(source_type, {})
619
+ target_path = allowed.get(target_type)
620
+ if target_path is None:
621
+ from_label = _DOC_META.get(source_type, {}).get("label", source_type)
622
+ options = ", ".join(_DOC_META[t]["label"] for t in allowed) or "keine"
623
+ raise LexwareError(
624
+ f"Ein {from_label} kann nicht zu '{_DOC_META[target_type]['label']}' "
625
+ f"weitergeführt werden. Von hier möglich: {options}."
626
+ )
627
+
628
+ body = _pursue_body(source)
629
+ try:
630
+ return client.post(
631
+ target_path, body, params={"precedingSalesVoucherId": source_id}
632
+ )
633
+ except LexwareAPIError as exc:
634
+ if exc.status_code in (400, 406):
635
+ raise LexwareError(
636
+ _pursue_error(exc, source_type, target_type)
637
+ ) from exc
638
+ raise
639
+
640
+
641
+ def _resolve_source(
642
+ client: LexwareClient, identifier: str
643
+ ) -> tuple[str, str, dict[str, Any]]:
644
+ """Determine a source document's (normalized type, id, full body).
645
+
646
+ A voucher number is looked up in the voucherlist (which reports the type).
647
+ A UUID is probed against each sales-voucher endpoint until one answers.
648
+ """
649
+ ident = identifier.strip()
650
+ if _looks_like_uuid(ident):
651
+ for source_type, meta in _DOC_META.items():
652
+ try:
653
+ doc = client.get(meta["path"].format(id=ident))
654
+ except LexwareAPIError as exc:
655
+ if exc.status_code == 404:
656
+ continue
657
+ raise
658
+ return source_type, ident, doc
659
+ raise LexwareError(
660
+ f"Kein Beleg mit der ID '{identifier}' gefunden."
661
+ )
662
+
663
+ # Voucher number: the voucherlist row carries the type and the real id.
664
+ result = client.get(
665
+ "/v1/voucherlist",
666
+ params={
667
+ "voucherNumber": ident,
668
+ "voucherType": ALL_VOUCHER_TYPES, # already a comma-joined string
669
+ "voucherStatus": "any",
670
+ "size": 2,
671
+ },
672
+ )
673
+ rows = result.get("content") or []
674
+ if not rows:
675
+ raise LexwareError(f"Kein Beleg mit Nummer '{identifier}' gefunden.")
676
+ row = rows[0]
677
+ raw_type = row.get("voucherType", "")
678
+ source_type = _SOURCE_TYPE_NORMALIZE.get(raw_type, raw_type)
679
+ meta = _DOC_META.get(source_type)
680
+ if meta is None:
681
+ raise LexwareError(
682
+ f"Belegtyp '{raw_type}' von '{identifier}' kann nicht weitergeführt "
683
+ "werden."
684
+ )
685
+ doc = get_with_voucher_fallback(client, meta["path"].format(id=row["id"]))
686
+ return source_type, row["id"], doc
687
+
688
+
689
+ def _looks_like_uuid(value: str) -> bool:
690
+ parts = value.split("-")
691
+ return len(parts) == 5 and all(
692
+ c in "0123456789abcdefABCDEF" for c in value.replace("-", "")
693
+ )
694
+
695
+
696
+ def _pursue_error(exc: LexwareAPIError, source_type: str, target_type: str) -> str:
697
+ """Turn a 400/406 from a pursue POST into a clear German message.
698
+
699
+ Lexware uses 406 for three cases: body validation (with a `details` list),
700
+ a not-yet-finalized preceding document, and a plain invalid transition.
701
+ Surface the most specific explanation available.
702
+ """
703
+ to_label = _DOC_META[target_type]["label"]
704
+ from_label = _DOC_META.get(source_type, {}).get("label", source_type)
705
+ body = exc.body if isinstance(exc.body, dict) else {}
706
+
707
+ details = body.get("details")
708
+ if details:
709
+ parts = [
710
+ f"{d.get('field', '?')}: {d.get('message', '')}".strip(": ")
711
+ for d in details
712
+ if isinstance(d, dict)
713
+ ]
714
+ joined = "; ".join(p for p in parts if p)
715
+ if joined:
716
+ return f"Weiterführen zu '{to_label}' nicht möglich: {joined}."
717
+
718
+ message = (body.get("message") or "").strip()
719
+ if "finali" in message.lower():
720
+ return (
721
+ f"Der Ausgangsbeleg ({from_label}) ist noch ein Entwurf. Er muss in "
722
+ f"Lexware erst festgeschrieben werden, bevor er zu '{to_label}' "
723
+ "weitergeführt werden kann."
724
+ )
725
+ if message:
726
+ return f"Weiterführen zu '{to_label}' nicht möglich: {message}"
727
+
728
+ return (
729
+ f"Dieser {from_label} lässt sich gerade nicht zu '{to_label}' weiterführen "
730
+ "(z. B. weil er storniert oder bereits weitergeführt ist)."
731
+ )
732
+
733
+
734
+ def _pursue_body(source: dict[str, Any]) -> dict[str, Any]:
735
+ """Turn a fetched document into a fresh create-body for its follow-up.
736
+
737
+ Drops server-managed/computed fields, re-dates the document to today, keeps
738
+ only the currency on ``totalPrice`` (amounts are recomputed server-side),
739
+ and strips per-line-item ids/amounts. Content (address, line items, tax /
740
+ shipping / payment conditions, texts, language) carries over unchanged.
741
+ """
742
+ body = {k: v for k, v in source.items() if k not in _PURSUE_DROP_DOC}
743
+ body["voucherDate"] = datetime.now().astimezone().isoformat(timespec="milliseconds")
744
+ body["lineItems"] = [
745
+ {k: v for k, v in item.items() if k not in _PURSUE_DROP_LINEITEM}
746
+ for item in (body.get("lineItems") or [])
747
+ ]
748
+ currency = (source.get("totalPrice") or {}).get("currency", "EUR")
749
+ body["totalPrice"] = {"currency": currency}
750
+ # Ensure the follow-up's required blocks exist even when the source type
751
+ # doesn't carry them (a quotation has no shippingConditions, but an order
752
+ # confirmation / invoice requires one).
753
+ if not body.get("taxConditions"):
754
+ body["taxConditions"] = {"taxType": "net"}
755
+ if not body.get("shippingConditions"):
756
+ body["shippingConditions"] = {"shippingType": "none"}
757
+ return body
758
+
759
+
503
760
  # -- Contacts ---------------------------------------------------------------
504
761
 
505
762
 
@@ -554,6 +811,44 @@ def create_contact(client: LexwareClient, body: dict[str, Any]) -> dict[str, Any
554
811
  return client.post("/v1/contacts", body)
555
812
 
556
813
 
814
+ def update_contact(
815
+ client: LexwareClient, contact_id: str, changes: dict[str, Any]
816
+ ) -> dict[str, Any]:
817
+ """Partially update a contact: fetch, deep-merge, PUT the full object.
818
+
819
+ Lexware's PUT replaces the whole contact and requires the current
820
+ ``version`` (optimistic locking). We therefore always fetch the live
821
+ contact first, merge the caller's ``changes`` onto it, and force the
822
+ just-fetched version — so the caller only ever supplies the fields that
823
+ change and can never break the version handshake.
824
+ """
825
+ current = client.get(f"/v1/contacts/{contact_id}")
826
+ merged = _merge_for_update(current, changes)
827
+ _guard_single_contact_person(merged)
828
+ return _put_and_return_object(client, f"/v1/contacts/{contact_id}", merged)
829
+
830
+
831
+ def _guard_single_contact_person(contact: dict[str, Any]) -> None:
832
+ """Fail early on contacts the Lexware REST API refuses to update.
833
+
834
+ A company contact may hold multiple contact persons in the Lexware web
835
+ app, but the REST API accepts **at most one** on create/update — a PUT with
836
+ more returns an opaque HTTP 406 (`invalid_value` on `contactPersons.size`).
837
+ Since our update always PUTs the full object, such a contact can't be
838
+ edited via the API at all, even for an unrelated field. Turn that into a
839
+ clear, actionable message instead of a raw 406.
840
+ """
841
+ persons = (contact.get("company") or {}).get("contactPersons") or []
842
+ if len(persons) > 1:
843
+ raise LexwareError(
844
+ "Dieser Kontakt hat mehrere Ansprechpartner. Die Lexware-API erlaubt "
845
+ "pro Firmen-Kontakt nur einen, deshalb lassen sich solche Kontakte "
846
+ "nicht über die API bearbeiten (auch nicht für andere Felder). Bitte "
847
+ "die Änderung direkt in der Lexware-Weboberfläche vornehmen. Für "
848
+ "weitere Ansprechpartner empfiehlt sich ein eigener Kontakt."
849
+ )
850
+
851
+
557
852
  # -- Articles ---------------------------------------------------------------
558
853
 
559
854
 
@@ -600,3 +895,55 @@ def get_article(client: LexwareClient, article_id: str) -> dict[str, Any]:
600
895
 
601
896
  def create_article(client: LexwareClient, body: dict[str, Any]) -> dict[str, Any]:
602
897
  return client.post("/v1/articles", body)
898
+
899
+
900
+ def update_article(
901
+ client: LexwareClient, article_id: str, changes: dict[str, Any]
902
+ ) -> dict[str, Any]:
903
+ """Partially update an article — same fetch/deep-merge/PUT flow as
904
+ :func:`update_contact` (articles also use ``version`` locking)."""
905
+ current = client.get(f"/v1/articles/{article_id}")
906
+ merged = _merge_for_update(current, changes)
907
+ return _put_and_return_object(client, f"/v1/articles/{article_id}", merged)
908
+
909
+
910
+ def _merge_for_update(current: dict[str, Any], changes: dict[str, Any]) -> dict[str, Any]:
911
+ """Deep-merge ``changes`` onto a freshly fetched resource for a PUT.
912
+
913
+ Nested objects merge key-by-key; lists and scalars are replaced wholesale
914
+ (a list can't be meaningfully half-merged — pass the full list to change
915
+ one entry). The resource's own ``version`` always wins, so a caller who
916
+ echoes a stale version in ``changes`` can't trigger a false 409.
917
+ """
918
+ merged = _deep_merge(current, changes)
919
+ if "version" in current:
920
+ merged["version"] = current["version"]
921
+ return merged
922
+
923
+
924
+ def _put_and_return_object(
925
+ client: LexwareClient, path: str, body: dict[str, Any]
926
+ ) -> dict[str, Any]:
927
+ """PUT ``body`` and return the full updated object, not Lexware's envelope.
928
+
929
+ Lexware's PUT responds with only a confirmation ({id, resourceUri, version,
930
+ createdDate, updatedDate}). The full object we just stored is far more
931
+ useful to the caller, so we return ``body`` with the new version stamped
932
+ in — no extra GET needed.
933
+ """
934
+ result = client.put(path, body)
935
+ updated = dict(body)
936
+ if isinstance(result, dict):
937
+ for key in ("version", "updatedDate", "id", "resourceUri"):
938
+ if key in result:
939
+ updated[key] = result[key]
940
+ return updated
941
+
942
+
943
+ def _deep_merge(base: Any, changes: Any) -> Any:
944
+ if isinstance(base, dict) and isinstance(changes, dict):
945
+ out = dict(base)
946
+ for key, value in changes.items():
947
+ out[key] = _deep_merge(base[key], value) if key in base else value
948
+ return out
949
+ return changes
@@ -0,0 +1,57 @@
1
+ """Compose a non-binding feature request for the user to send to the vendor.
2
+
3
+ This is an **end-customer product**: the assistant must not offer to build or
4
+ extend the software itself. When a user wants functionality the tools don't
5
+ cover, the ``request_feature`` MCP tool returns a ready-to-send message (subject
6
+ + body) and the vendor's address (oemedia) — the user copies it and sends the
7
+ email themselves. The server does **not** send any mail. No promise is made
8
+ about whether or when the feature is built.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ DEFAULT_TO = "david@oemedia.de"
14
+ SUBJECT = "Lexware-MCP — Funktionsanfrage (unverbindlich)"
15
+
16
+
17
+ class FeatureRequestError(Exception):
18
+ """Raised when a feature request cannot be composed."""
19
+
20
+
21
+ def compose_feature_request(
22
+ *,
23
+ description: str,
24
+ company: str | None = None,
25
+ contact_email: str | None = None,
26
+ ) -> dict[str, str]:
27
+ """Build a copy-paste-ready feature request. Sends nothing.
28
+
29
+ Returns ``{to, subject, body, hinweis}``. Raises
30
+ :class:`FeatureRequestError` on empty input.
31
+ """
32
+ text = (description or "").strip()
33
+ if not text:
34
+ raise FeatureRequestError("Bitte den gewünschten Funktionswunsch beschreiben.")
35
+
36
+ subject = SUBJECT + (f" — {company}" if company else "")
37
+ lines = [
38
+ "Unverbindliche Funktionsanfrage zum Lexware-MCP-Tool.",
39
+ "",
40
+ f"Kunde: {company or '(bitte ergänzen)'}",
41
+ f"Kontakt-Mail: {contact_email or '(bitte ergänzen)'}",
42
+ "",
43
+ "Wunsch:",
44
+ text,
45
+ "",
46
+ "Hinweis: unverbindlich, keine Zusage zur Umsetzung.",
47
+ ]
48
+ return {
49
+ "to": DEFAULT_TO,
50
+ "subject": subject,
51
+ "body": "\n".join(lines),
52
+ "hinweis": (
53
+ "Dies ist ein unverbindlicher Funktionswunsch. Bitte den obigen Text "
54
+ f"per E-Mail an {DEFAULT_TO} senden — der Server verschickt nichts "
55
+ "selbst."
56
+ ),
57
+ }