Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3370d94b59 | ||
|
|
6d69eebd4d | ||
|
|
96e105fe92 | ||
|
|
0ccb21303d | ||
|
|
bdc4260d59 | ||
|
|
5f45dff1a7 | ||
|
|
e2642e51a7 |
@@ -6,6 +6,31 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
## [Unveröffentlicht]
|
## [Unveröffentlicht]
|
||||||
|
|
||||||
|
## [0.8.0] – 2026-09-13
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
- **MCP-Server** unter `/api/mcp` mit sechs Werkzeugen: Aufgaben suchen,
|
||||||
|
Woche ansehen, Kalender auflisten, Aufgabe anlegen, abhaken und einplanen.
|
||||||
|
Beschreibung in [docs/MCP.md](docs/MCP.md).
|
||||||
|
- Die Werkzeugliste richtet sich nach den Rechten des Tokens — was ein Token
|
||||||
|
nicht darf, sieht das Modell gar nicht erst.
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- Das Einplanen liegt jetzt an einer Stelle, die sowohl die Oberfläche als
|
||||||
|
auch der MCP-Server benutzen. Ein über MCP angelegter Zeitblock verhält
|
||||||
|
sich damit genau wie ein von Hand gezogener.
|
||||||
|
|
||||||
|
### Sicherheit
|
||||||
|
|
||||||
|
- MCP benutzt dieselben Tokens und dieselben Rechte wie die Schnittstelle.
|
||||||
|
Ein Sprachmodell bekommt keinen eigenen Zugang.
|
||||||
|
- Jede schreibende Nutzung steht im Protokoll, mit dem Vermerk, dass sie
|
||||||
|
über MCP kam.
|
||||||
|
- Der Server kann weder löschen noch Bereiche, Projekte, Kalenderkonten,
|
||||||
|
Einstellungen oder Tokens anfassen.
|
||||||
|
|
||||||
## [0.7.0] – 2026-09-13
|
## [0.7.0] – 2026-09-13
|
||||||
|
|
||||||
### Hinzugefügt
|
### Hinzugefügt
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ wird.
|
|||||||
Planer lässt sich als App auf dem Startbildschirm ablegen
|
Planer lässt sich als App auf dem Startbildschirm ablegen
|
||||||
- **Öffentliche Schnittstelle** mit Tokens je Anwendung, Rechten je Token und
|
- **Öffentliche Schnittstelle** mit Tokens je Anwendung, Rechten je Token und
|
||||||
jeder schreibenden Nutzung im Protokoll ([Beschreibung](docs/API.md))
|
jeder schreibenden Nutzung im Protokoll ([Beschreibung](docs/API.md))
|
||||||
|
- **MCP-Server** für Sprachmodelle — dieselben Tokens, dieselben Rechte
|
||||||
|
([Beschreibung](docs/MCP.md))
|
||||||
- Anmeldung über einen OIDC-Anbieter, über Passkeys oder mit E-Mail und
|
- Anmeldung über einen OIDC-Anbieter, über Passkeys oder mit E-Mail und
|
||||||
Passwort — je Instanz einzeln zuschaltbar
|
Passwort — je Instanz einzeln zuschaltbar
|
||||||
- Rollen `Benutzer` und `Verwaltung`; jede schreibende Aktion wird
|
- Rollen `Benutzer` und `Verwaltung`; jede schreibende Aktion wird
|
||||||
@@ -51,9 +53,10 @@ wird.
|
|||||||
- Hell und Dunkel, Oberfläche durchgehend auf Deutsch
|
- Hell und Dunkel, Oberfläche durchgehend auf Deutsch
|
||||||
- Betrieb über Docker Compose, mit Sicherung, Aktualisierung und Healthcheck
|
- Betrieb über Docker Compose, mit Sicherung, Aktualisierung und Healthcheck
|
||||||
|
|
||||||
### Geplant
|
### Was noch kommen könnte
|
||||||
|
|
||||||
Ein MCP-Server. Siehe [ROADMAP.md](ROADMAP.md).
|
Der [Fahrplan](ROADMAP.md) ist abgearbeitet. Was danach denkbar wäre, steht
|
||||||
|
dort unter „Ideen für später" — und was bewusst nicht kommt, gleich darunter.
|
||||||
|
|
||||||
## Bildergalerie
|
## Bildergalerie
|
||||||
|
|
||||||
|
|||||||
+14
-4
@@ -12,7 +12,7 @@ grünen Prüfungen, aktueller Dokumentation und einem Eintrag im DEVLOG.
|
|||||||
| [M5](#m5--benachrichtigungen) | Benachrichtigungen | erledigt |
|
| [M5](#m5--benachrichtigungen) | Benachrichtigungen | erledigt |
|
||||||
| [M6](#m6--integrationen) | Integrationen | erledigt |
|
| [M6](#m6--integrationen) | Integrationen | erledigt |
|
||||||
| [M7](#m7--schnellerfassung-und-api) | Schnellerfassung und API | erledigt |
|
| [M7](#m7--schnellerfassung-und-api) | Schnellerfassung und API | erledigt |
|
||||||
| [M8](#m8--mcp) | MCP | geplant |
|
| [M8](#m8--mcp) | MCP | erledigt |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -156,9 +156,19 @@ Zeremonie als Schutz.
|
|||||||
|
|
||||||
## M8 – MCP
|
## M8 – MCP
|
||||||
|
|
||||||
- [ ] MCP-Server über `@modelcontextprotocol/sdk`
|
**Erledigt am 13.09.2026 mit Version 0.8.0.**
|
||||||
- [ ] Lesen und Anlegen von Aufgaben und Zeitblöcken
|
|
||||||
- [ ] Rechte wie bei der API, jede Nutzung im Protokoll
|
**Ziel:** Ein Sprachmodell soll den Alltag erleichtern können — ohne einen
|
||||||
|
eigenen, weicheren Zugang zu bekommen als jede andere fremde Anwendung.
|
||||||
|
|
||||||
|
- [x] MCP-Server über `@modelcontextprotocol/sdk`
|
||||||
|
- [x] Lesen und Anlegen von Aufgaben und Zeitblöcken
|
||||||
|
- [x] Rechte wie bei der API, jede schreibende Nutzung im Protokoll
|
||||||
|
- [x] Werkzeugliste richtet sich nach den Rechten des Tokens
|
||||||
|
|
||||||
|
**Bewusst offen gelassen:** Der Server kann nichts löschen, keine Bereiche
|
||||||
|
oder Projekte anlegen und keine Einstellungen anfassen. Ein Modell soll den
|
||||||
|
Alltag erleichtern, nicht die Ordnung umbauen.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+74
-1
@@ -14,7 +14,80 @@ die Übersicht aus `scripts/zeit.sh summe`.
|
|||||||
| M5 | claude | 420 | 7,00 |
|
| M5 | claude | 420 | 7,00 |
|
||||||
| M6 | claude | 733 | 12,22 |
|
| M6 | claude | 733 | 12,22 |
|
||||||
| M7 | claude | 886 | 14,77 |
|
| M7 | claude | 886 | 14,77 |
|
||||||
| **Gesamt** | | **2452** | **40,87** |
|
| M8 | claude | 69 | 1,15 |
|
||||||
|
| **Gesamt** | | **2521** | **42,02** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-13 – M8: MCP
|
||||||
|
|
||||||
|
**Dauer:** 69 Minuten (14:10–15:19), Art `claude`.
|
||||||
|
|
||||||
|
> Der kürzeste Meilenstein — weil das Fundament schon stand. Tokens, Rechte
|
||||||
|
> und Protokoll kamen mit M7, der Kalenderschreibweg mit M4. Übrig blieb das
|
||||||
|
> Zusammenstecken.
|
||||||
|
|
||||||
|
**Ziel der Sitzung:** Ein Sprachmodell soll den Alltag erleichtern können —
|
||||||
|
ohne einen eigenen, weicheren Zugang zu bekommen als jede andere fremde
|
||||||
|
Anwendung.
|
||||||
|
|
||||||
|
### Erledigt
|
||||||
|
|
||||||
|
- MCP-Server unter `/api/mcp` über `@modelcontextprotocol/sdk`
|
||||||
|
- Sechs Werkzeuge: suchen, Woche ansehen, Kalender auflisten, anlegen,
|
||||||
|
abhaken, einplanen
|
||||||
|
- Das Einplanen aus der Serveraktion herausgelöst, damit Oberfläche und MCP
|
||||||
|
dieselbe Stelle benutzen
|
||||||
|
- 6 neue E2E-Tests, Beschreibung in `docs/MCP.md`
|
||||||
|
|
||||||
|
### Entscheidungen mit Begründung
|
||||||
|
|
||||||
|
**Dieselben Tokens, dieselben Rechte wie die Schnittstelle.** Ein
|
||||||
|
Sprachmodell ist aus Sicht des Planers eine fremde Anwendung wie jede andere.
|
||||||
|
Ein eigener Zugangsweg wäre ein zweiter Satz Regeln — und der zweite Satz ist
|
||||||
|
immer der, den man vergisst zu pflegen.
|
||||||
|
|
||||||
|
**Die Werkzeugliste richtet sich nach den Rechten.** Was ein Token nicht
|
||||||
|
darf, taucht gar nicht erst auf. Ein Modell soll nicht gegen eine Tür laufen,
|
||||||
|
die für es verschlossen ist; und eine Fehlermeldung als Antwort auf einen
|
||||||
|
Werkzeugaufruf ist verschwendeter Kontext.
|
||||||
|
|
||||||
|
**Wenige, grob geschnittene Werkzeuge.** Ein Modell, das zwischen zwanzig
|
||||||
|
fast gleichen wählen muss, wählt falsch. Jedes weitere Werkzeug ist zudem
|
||||||
|
eine zusätzliche Möglichkeit, versehentlich etwas zu ändern.
|
||||||
|
|
||||||
|
**Der Server kann nichts löschen.** Abhaken ja, wegräumen nein. Auch keine
|
||||||
|
Bereiche, Projekte oder Einstellungen anfassen. Ein Modell soll den Alltag
|
||||||
|
erleichtern, nicht die Ordnung umbauen.
|
||||||
|
|
||||||
|
**Das Einplanen liegt jetzt an einer gemeinsamen Stelle** — und die liegt
|
||||||
|
bewusst **nicht** in einer Datei mit `'use server'`. Dort wird jede
|
||||||
|
exportierte Funktion zu einem Endpunkt, den der Browser aufrufen kann; eine
|
||||||
|
Funktion, die den Akteur als Parameter nimmt, wäre dann ein offenes Tor.
|
||||||
|
|
||||||
|
**Zustandslos.** Der Planer hat keine langlaufenden Vorgänge, und eine
|
||||||
|
Sitzungsverwaltung wäre Zustand, den mehrere Container nicht teilen könnten.
|
||||||
|
|
||||||
|
### Probleme und Lösungen
|
||||||
|
|
||||||
|
| Problem | Lösung |
|
||||||
|
| --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Der SDK-Transport erwartet Node-Streams, Next liefert `Request` | Das SDK bringt seit 1.30 `WebStandardStreamableHTTPServerTransport` mit — `handleRequest(Request): Promise<Response>` passt direkt. |
|
||||||
|
| ESLint beanstandete das Auslesen der Formularfelder | `FormData.get` kann auch eine Datei liefern; die würde beim Stringify zu `[object Object]`. Ein Helfer nimmt jetzt nur Text. |
|
||||||
|
|
||||||
|
### Offene Punkte
|
||||||
|
|
||||||
|
- Der Server ist gegen die eigenen Tests geprüft, nicht gegen einen echten
|
||||||
|
Client. Ein Lauf mit Claude Code gegen die laufende Instanz wäre der
|
||||||
|
Beweis, dass Handschlag und Werkzeugbeschreibungen auch dort taugen.
|
||||||
|
- Die Handbuchbilder sind unverändert aus M7 — der MCP-Server hat keine
|
||||||
|
Oberfläche, es gibt nichts abzubilden.
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
|
||||||
|
- 5f45dff refactor: Einplanen aus der Serveraktion herauslösen
|
||||||
|
- bdc4260 feat: MCP-Server über denselben Token wie die API
|
||||||
|
- (docs) MCP beschreiben, Fahrplan abschließen
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ datum;start;ende;minuten;meilenstein;art;notiz
|
|||||||
2026-09-11;06:35;13:35;420;M5;claude;M5 Benachrichtigungen fertig
|
2026-09-11;06:35;13:35;420;M5;claude;M5 Benachrichtigungen fertig
|
||||||
2026-09-11;20:47;09:00;733;M6;claude;M6 Integrationen fertig
|
2026-09-11;20:47;09:00;733;M6;claude;M6 Integrationen fertig
|
||||||
2026-09-12;10:15;01:01;886;M7;claude;M7 Schnellerfassung und API fertig
|
2026-09-12;10:15;01:01;886;M7;claude;M7 Schnellerfassung und API fertig
|
||||||
|
2026-09-13;14:10;15:19;69;M8;claude;M8 MCP fertig
|
||||||
|
|||||||
|
+83
@@ -0,0 +1,83 @@
|
|||||||
|
# MCP-Server
|
||||||
|
|
||||||
|
Der Planer spricht das Model Context Protocol. Damit kann ein Sprachmodell
|
||||||
|
Aufgaben suchen, anlegen, abhaken und einplanen — mit denselben Tokens und
|
||||||
|
denselben Rechten wie die [Schnittstelle](API.md).
|
||||||
|
|
||||||
|
Adresse: `<APP_URL>/api/mcp`
|
||||||
|
|
||||||
|
## Einrichten
|
||||||
|
|
||||||
|
1. In der Anwendung unter **API** einen Token anlegen. Gib ihm nur, was das
|
||||||
|
Modell wirklich braucht.
|
||||||
|
2. Den Client auf die Adresse zeigen lassen, mit dem Token als Bearer.
|
||||||
|
|
||||||
|
Für Claude Code:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude mcp add planer --transport http https://planer.example.de/api/mcp \
|
||||||
|
--header "Authorization: Bearer planer_…"
|
||||||
|
```
|
||||||
|
|
||||||
|
Für Clients, die nur stdio können, dazwischen `mcp-remote` setzen:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"planer": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"mcp-remote",
|
||||||
|
"https://planer.example.de/api/mcp",
|
||||||
|
"--header",
|
||||||
|
"Authorization: Bearer planer_…"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Werkzeuge
|
||||||
|
|
||||||
|
Welche ein Modell zu sehen bekommt, hängt am Token. Was nicht erlaubt ist,
|
||||||
|
taucht gar nicht erst in der Liste auf — ein Modell soll nicht gegen eine Tür
|
||||||
|
laufen, die für es verschlossen ist.
|
||||||
|
|
||||||
|
| Werkzeug | Recht | Was es tut |
|
||||||
|
| -------------------- | ----------- | ----------------------------------------------- |
|
||||||
|
| `aufgaben_suchen` | `lesen` | Aufgaben auflisten, optional gefiltert |
|
||||||
|
| `woche_ansehen` | `lesen` | Zeitblöcke einer Woche mit Summe |
|
||||||
|
| `kalender_auflisten` | `lesen` | Verbundene Kalender samt Kennung |
|
||||||
|
| `aufgabe_anlegen` | `erfassen` | Neue Aufgabe in der Inbox |
|
||||||
|
| `aufgabe_abhaken` | `schreiben` | Aufgabe auf erledigt setzen |
|
||||||
|
| `zeitblock_anlegen` | `schreiben` | Aufgabe einplanen und in den Kalender schreiben |
|
||||||
|
|
||||||
|
Zum Einplanen braucht es die Kennung eines Kalenderkontos — daher erst
|
||||||
|
`kalender_auflisten`, dann `zeitblock_anlegen`.
|
||||||
|
|
||||||
|
`zeitblock_anlegen` geht durch dieselbe Stelle wie das Ziehen in der
|
||||||
|
Wochenansicht: Rasterprüfung, Kalenderschreibweg, Schleifenschutz, Protokoll.
|
||||||
|
Ein über MCP angelegter Zeitblock verhält sich also genau wie ein von Hand
|
||||||
|
gezogener.
|
||||||
|
|
||||||
|
## Was der Server bewusst **nicht** kann
|
||||||
|
|
||||||
|
- **Löschen.** Weder Aufgaben noch Zeitblöcke. Abhaken ja, wegräumen nein.
|
||||||
|
- **Bereich oder Projekt setzen.** Was hier entsteht, landet in der Inbox.
|
||||||
|
- **Bereiche, Projekte oder Kalenderkonten anlegen und ändern.**
|
||||||
|
- **Einstellungen oder Tokens anfassen.**
|
||||||
|
|
||||||
|
Ein Modell soll den Alltag erleichtern, nicht die Ordnung umbauen.
|
||||||
|
|
||||||
|
## Protokoll
|
||||||
|
|
||||||
|
Jede schreibende Nutzung landet im Protokoll — mit dem Vermerk, dass sie über
|
||||||
|
MCP kam, und mit dem Token. Ein Modell, das Aufgaben anlegt, muss genauso
|
||||||
|
nachvollziehbar sein wie ein Mensch. Eher noch mehr.
|
||||||
|
|
||||||
|
## Ohne Sitzungen
|
||||||
|
|
||||||
|
Der Server ist zustandslos: Jede Anfrage steht für sich. Der Planer hat keine
|
||||||
|
langlaufenden Vorgänge, und eine Sitzungsverwaltung wäre Zustand, den mehrere
|
||||||
|
Container nicht teilen könnten.
|
||||||
+1
-10
@@ -31,18 +31,9 @@ Anleitung für den Alltag mit dem Planer. Eine Seite je Thema.
|
|||||||
|
|
||||||
11. **[Protokoll](protokoll.md)** – nachsehen, wer wann was geändert hat
|
11. **[Protokoll](protokoll.md)** – nachsehen, wer wann was geändert hat
|
||||||
|
|
||||||
## Was noch kommt
|
|
||||||
|
|
||||||
Diese Seiten entstehen mit den nächsten Meilensteinen:
|
|
||||||
|
|
||||||
| Thema | Meilenstein |
|
|
||||||
| ----- | ----------- |
|
|
||||||
| MCP | M8 |
|
|
||||||
|
|
||||||
Der [Fahrplan](../../ROADMAP.md) sagt, was in welchem Meilenstein steckt.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Für fremde Anwendungen und Skripte: [Schnittstelle](../API.md).
|
Für fremde Anwendungen und Skripte: [Schnittstelle](../API.md).
|
||||||
|
Für Sprachmodelle: [MCP-Server](../MCP.md).
|
||||||
Zur Einrichtung einer neuen Instanz: [Schnellstart](../SCHNELLSTART.md).
|
Zur Einrichtung einer neuen Instanz: [Schnellstart](../SCHNELLSTART.md).
|
||||||
Für Betrieb, Sicherung und alle Umgebungsvariablen: [README](../../README.md).
|
Für Betrieb, Sicherung und alle Umgebungsvariablen: [README](../../README.md).
|
||||||
|
|||||||
+173
@@ -0,0 +1,173 @@
|
|||||||
|
import { createHash, randomBytes } from 'node:crypto'
|
||||||
|
import { PrismaPg } from '@prisma/adapter-pg'
|
||||||
|
import { expect, test, type APIRequestContext } from '@playwright/test'
|
||||||
|
import { PrismaClient } from '../src/generated/prisma/client.js'
|
||||||
|
import { EMAIL, VERBINDUNG } from './hilfe'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der MCP-Endpunkt.
|
||||||
|
*
|
||||||
|
* Er spricht JSON-RPC, also wird hier auch JSON-RPC gesprochen — kein
|
||||||
|
* Nachbau der Bibliothek, sondern echte Aufrufe gegen den laufenden Server.
|
||||||
|
*
|
||||||
|
* Der wichtigste Punkt: Die Werkzeugliste hängt an den Rechten des Tokens.
|
||||||
|
* Was ein Token nicht darf, soll ein Modell gar nicht erst sehen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: VERBINDUNG }) })
|
||||||
|
const PRAEFIX = `E2E-MCP-${Date.now()}`
|
||||||
|
|
||||||
|
async function tokenBauen(benutzerId: string, rechte: string): Promise<string> {
|
||||||
|
const praefix = randomBytes(4).toString('hex')
|
||||||
|
const klartext = `planer_${praefix}_${randomBytes(32).toString('base64url')}`
|
||||||
|
await db.apiToken.create({
|
||||||
|
data: {
|
||||||
|
benutzerId,
|
||||||
|
name: `${PRAEFIX} ${rechte}`,
|
||||||
|
praefix,
|
||||||
|
hash: createHash('sha256').update(klartext, 'utf8').digest('hex'),
|
||||||
|
rechte,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return klartext
|
||||||
|
}
|
||||||
|
|
||||||
|
type Antwort = {
|
||||||
|
result?: { tools?: { name: string }[]; content?: { text: string }[]; isError?: boolean }
|
||||||
|
error?: { message: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein JSON-RPC-Aufruf gegen den Endpunkt. */
|
||||||
|
async function rufen(
|
||||||
|
request: APIRequestContext,
|
||||||
|
token: string | null,
|
||||||
|
methode: string,
|
||||||
|
parameter: Record<string, unknown> = {},
|
||||||
|
) {
|
||||||
|
const antwort = await request.post('/api/mcp', {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
// Der Streamable-HTTP-Transport besteht auf beidem.
|
||||||
|
Accept: 'application/json, text/event-stream',
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
data: { jsonrpc: '2.0', id: 1, method: methode, params: parameter },
|
||||||
|
})
|
||||||
|
return antwort
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der Handschlag, den jede MCP-Verbindung zuerst führt. */
|
||||||
|
const HANDSCHLAG = {
|
||||||
|
protocolVersion: '2025-06-18',
|
||||||
|
capabilities: {},
|
||||||
|
clientInfo: { name: 'e2e', version: '1.0.0' },
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('MCP', () => {
|
||||||
|
test.skip(!EMAIL || !VERBINDUNG, 'Ohne ADMIN_EMAIL und DATABASE_URL kein Testkonto.')
|
||||||
|
|
||||||
|
let benutzerId = ''
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
const benutzer = await db.benutzer.findUniqueOrThrow({ where: { email: EMAIL } })
|
||||||
|
benutzerId = benutzer.id
|
||||||
|
})
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await db.aufgabe.deleteMany({ where: { titel: { startsWith: PRAEFIX } } })
|
||||||
|
await db.apiToken.deleteMany({ where: { name: { startsWith: PRAEFIX } } })
|
||||||
|
await db.auditEintrag.deleteMany({ where: { aktion: { startsWith: 'mcp.' } } })
|
||||||
|
await db.$disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('weist eine Anfrage ohne Token ab', async ({ request }) => {
|
||||||
|
const antwort = await rufen(request, null, 'initialize', HANDSCHLAG)
|
||||||
|
expect(antwort.status()).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('führt den Handschlag mit gültigem Token', async ({ request }) => {
|
||||||
|
const token = await tokenBauen(benutzerId, 'lesen')
|
||||||
|
const antwort = await rufen(request, token, 'initialize', HANDSCHLAG)
|
||||||
|
|
||||||
|
expect(antwort.ok()).toBe(true)
|
||||||
|
const inhalt = (await antwort.json()) as {
|
||||||
|
result?: { serverInfo?: { name: string } }
|
||||||
|
}
|
||||||
|
expect(inhalt.result?.serverInfo?.name).toBe('planer')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('zeigt nur die Werkzeuge, die der Token erlaubt', async ({ request }) => {
|
||||||
|
const nurLesen = await tokenBauen(benutzerId, 'lesen')
|
||||||
|
await rufen(request, nurLesen, 'initialize', HANDSCHLAG)
|
||||||
|
|
||||||
|
const antwort = await rufen(request, nurLesen, 'tools/list')
|
||||||
|
const namen = (((await antwort.json()) as Antwort).result?.tools ?? []).map(
|
||||||
|
(w) => w.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(namen).toContain('aufgaben_suchen')
|
||||||
|
expect(namen).toContain('woche_ansehen')
|
||||||
|
// Erfassen und Schreiben fehlen dem Token – und damit der Liste.
|
||||||
|
expect(namen).not.toContain('aufgabe_anlegen')
|
||||||
|
expect(namen).not.toContain('aufgabe_abhaken')
|
||||||
|
expect(namen).not.toContain('zeitblock_anlegen')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('zeigt einem Erfassen-Token nur das Anlegen', async ({ request }) => {
|
||||||
|
const token = await tokenBauen(benutzerId, 'erfassen')
|
||||||
|
await rufen(request, token, 'initialize', HANDSCHLAG)
|
||||||
|
|
||||||
|
const antwort = await rufen(request, token, 'tools/list')
|
||||||
|
const namen = (((await antwort.json()) as Antwort).result?.tools ?? []).map(
|
||||||
|
(w) => w.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(namen).toEqual(['aufgabe_anlegen'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('legt eine Aufgabe an und protokolliert das', async ({ request }) => {
|
||||||
|
const token = await tokenBauen(benutzerId, 'erfassen')
|
||||||
|
await rufen(request, token, 'initialize', HANDSCHLAG)
|
||||||
|
|
||||||
|
const titel = `${PRAEFIX} Angebot schreiben`
|
||||||
|
const antwort = await rufen(request, token, 'tools/call', {
|
||||||
|
name: 'aufgabe_anlegen',
|
||||||
|
arguments: { titel, aufwandMinuten: 45 },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(antwort.ok()).toBe(true)
|
||||||
|
const inhalt = (await antwort.json()) as Antwort
|
||||||
|
expect(inhalt.result?.content?.[0]?.text).toContain('Angelegt')
|
||||||
|
|
||||||
|
const aufgabe = await db.aufgabe.findFirstOrThrow({
|
||||||
|
where: { titel },
|
||||||
|
select: { quelle: true, bereichId: true, status: true, aufwandMinuten: true },
|
||||||
|
})
|
||||||
|
expect(aufgabe.quelle).toBe('MCP')
|
||||||
|
expect(aufgabe.aufwandMinuten).toBe(45)
|
||||||
|
// Wie über die Schnittstelle: offen und in der Inbox.
|
||||||
|
expect(aufgabe.status).toBe('OFFEN')
|
||||||
|
expect(aufgabe.bereichId).toBeNull()
|
||||||
|
|
||||||
|
const eintrag = await db.auditEintrag.findFirst({
|
||||||
|
where: { aktion: 'mcp.erfasst' },
|
||||||
|
select: { details: true },
|
||||||
|
})
|
||||||
|
expect(JSON.stringify(eintrag?.details)).toContain('mcp')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('lässt ein Werkzeug ohne Recht gar nicht erst aufrufen', async ({ request }) => {
|
||||||
|
const token = await tokenBauen(benutzerId, 'lesen')
|
||||||
|
await rufen(request, token, 'initialize', HANDSCHLAG)
|
||||||
|
|
||||||
|
const antwort = await rufen(request, token, 'tools/call', {
|
||||||
|
name: 'aufgabe_anlegen',
|
||||||
|
arguments: { titel: `${PRAEFIX} darf nicht` },
|
||||||
|
})
|
||||||
|
|
||||||
|
const inhalt = (await antwort.json()) as Antwort
|
||||||
|
// Unbekanntes Werkzeug: Es ist für diesen Token nicht registriert.
|
||||||
|
expect(inhalt.error ?? inhalt.result?.isError).toBeTruthy()
|
||||||
|
expect(await db.aufgabe.count({ where: { titel: `${PRAEFIX} darf nicht` } })).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
Generated
+786
-31
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "planer",
|
"name": "planer",
|
||||||
"version": "0.7.0",
|
"version": "0.8.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Aufgaben, Ideen, Projekte und Zeitblöcke mit Anbindung an Microsoft 365 und Google Kalender",
|
"description": "Aufgaben, Ideen, Projekte und Zeitblöcke mit Anbindung an Microsoft 365 und Google Kalender",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -42,6 +42,7 @@
|
|||||||
"@date-fns/tz": "1.5.0",
|
"@date-fns/tz": "1.5.0",
|
||||||
"@dnd-kit/core": "6.3.1",
|
"@dnd-kit/core": "6.3.1",
|
||||||
"@dnd-kit/utilities": "3.2.2",
|
"@dnd-kit/utilities": "3.2.2",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||||
"@prisma/adapter-pg": "7.10.0",
|
"@prisma/adapter-pg": "7.10.0",
|
||||||
"@prisma/client": "7.10.0",
|
"@prisma/client": "7.10.0",
|
||||||
"@radix-ui/react-avatar": "1.2.6",
|
"@radix-ui/react-avatar": "1.2.6",
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'
|
||||||
|
import { tokenAusKopfzeilen } from '@/lib/api'
|
||||||
|
import { tokenPruefen } from '@/server/api/token'
|
||||||
|
import { serverBauen } from '@/server/mcp/werkzeuge'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der MCP-Endpunkt.
|
||||||
|
*
|
||||||
|
* Ausgewiesen wird sich mit demselben Token wie bei `/api/v1` — ein
|
||||||
|
* Sprachmodell bekommt keinen eigenen, weicheren Zugang. Welche Werkzeuge es
|
||||||
|
* überhaupt zu sehen bekommt, hängt an den Rechten des Tokens: Was nicht
|
||||||
|
* erlaubt ist, taucht gar nicht erst in der Liste auf.
|
||||||
|
*
|
||||||
|
* Ohne Sitzungen: Jede Anfrage steht für sich. Der Planer hat keine
|
||||||
|
* langlaufenden Vorgänge, und eine Sitzungsverwaltung wäre Zustand, den
|
||||||
|
* mehrere Container nicht teilen könnten.
|
||||||
|
*/
|
||||||
|
async function bedienen(anfrage: Request): Promise<Response> {
|
||||||
|
const aufrufer = await tokenPruefen(
|
||||||
|
tokenAusKopfzeilen(
|
||||||
|
anfrage.headers.get('Authorization'),
|
||||||
|
anfrage.headers.get('X-Planer-Token'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!aufrufer) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
error: { code: -32001, message: 'Kein gültiger Token.' },
|
||||||
|
id: null,
|
||||||
|
},
|
||||||
|
{ status: 401, headers: { 'WWW-Authenticate': 'Bearer' } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = serverBauen(aufrufer)
|
||||||
|
const transport = new WebStandardStreamableHTTPServerTransport({
|
||||||
|
// Zustandslos: kein sessionIdGenerator.
|
||||||
|
enableJsonResponse: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
await server.connect(transport)
|
||||||
|
try {
|
||||||
|
return await transport.handleRequest(anfrage)
|
||||||
|
} finally {
|
||||||
|
// Jede Anfrage bekommt einen frischen Server; sonst blieben Verbindungen
|
||||||
|
// offen, die niemand mehr schliesst.
|
||||||
|
await transport.close()
|
||||||
|
await server.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(anfrage: Request) {
|
||||||
|
return bedienen(anfrage)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(anfrage: Request) {
|
||||||
|
return bedienen(anfrage)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(anfrage: Request) {
|
||||||
|
return bedienen(anfrage)
|
||||||
|
}
|
||||||
@@ -2,12 +2,7 @@
|
|||||||
|
|
||||||
import { revalidatePath } from 'next/cache'
|
import { revalidatePath } from 'next/cache'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import {
|
import { zeitraumHash, LAGE_FEHLER_TEXT, lagePruefen } from '@/lib/zeitblock'
|
||||||
zeitblockSchema,
|
|
||||||
zeitraumHash,
|
|
||||||
LAGE_FEHLER_TEXT,
|
|
||||||
lagePruefen,
|
|
||||||
} from '@/lib/zeitblock'
|
|
||||||
import { auditSchreiben } from '@/server/audit'
|
import { auditSchreiben } from '@/server/audit'
|
||||||
import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung'
|
import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung'
|
||||||
import { db } from '@/server/db'
|
import { db } from '@/server/db'
|
||||||
@@ -18,6 +13,7 @@ import {
|
|||||||
terminAnlegen,
|
terminAnlegen,
|
||||||
terminEntfernen,
|
terminEntfernen,
|
||||||
} from '@/server/kalender/schreiben'
|
} from '@/server/kalender/schreiben'
|
||||||
|
import { betreffFuer, zeitblockAnlegenFuer } from '@/server/zeitblock/dienst'
|
||||||
import { jetzt } from '@/server/zeitgeber'
|
import { jetzt } from '@/server/zeitgeber'
|
||||||
|
|
||||||
export type Rueckmeldung =
|
export type Rueckmeldung =
|
||||||
@@ -56,11 +52,6 @@ async function eigenerZeitblock(id: string, benutzerId: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Was im Kalender als Betreff steht. */
|
|
||||||
function betreffFuer(titel: string): string {
|
|
||||||
return titel
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Anlegen ---------------------------------------------------------------
|
// --- Anlegen ---------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -76,99 +67,39 @@ export async function zeitblockAnlegen(daten: FormData): Promise<Rueckmeldung> {
|
|||||||
if ('antwort' in geprueft) return geprueft.antwort
|
if ('antwort' in geprueft) return geprueft.antwort
|
||||||
const { akteur } = geprueft
|
const { akteur } = geprueft
|
||||||
|
|
||||||
const gelesen = zeitblockSchema.safeParse({
|
const ergebnis = await zeitblockAnlegenFuer(
|
||||||
aufgabeId: daten.get('aufgabeId'),
|
{ id: akteur.id, email: akteur.email },
|
||||||
kalenderkontoId: daten.get('kalenderkontoId'),
|
{
|
||||||
beginn: daten.get('beginn'),
|
aufgabeId: feld(daten, 'aufgabeId'),
|
||||||
ende: daten.get('ende'),
|
kalenderkontoId: feld(daten, 'kalenderkontoId'),
|
||||||
})
|
beginn: new Date(feld(daten, 'beginn')),
|
||||||
if (!gelesen.success) {
|
ende: new Date(feld(daten, 'ende')),
|
||||||
const lage = gelesen.error.issues.find((i) => i.path.includes('ende'))
|
|
||||||
return { ok: false, fehler: lage?.message ?? 'Die Angaben stimmen nicht.' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const [aufgabe, konto] = await Promise.all([
|
|
||||||
db.aufgabe.findUnique({
|
|
||||||
where: { id: gelesen.data.aufgabeId },
|
|
||||||
select: { id: true, titel: true, notiz: true, status: true },
|
|
||||||
}),
|
|
||||||
db.kalenderkonto.findFirst({
|
|
||||||
where: { id: gelesen.data.kalenderkontoId, benutzerId: akteur.id, aktiv: true },
|
|
||||||
select: { id: true, anbieter: true, kalenderId: true, anzeigename: true },
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
|
|
||||||
if (!aufgabe) return { ok: false, fehler: 'Diese Aufgabe gibt es nicht.' }
|
|
||||||
if (!konto) return { ok: false, fehler: 'Dieses Kalenderkonto gibt es nicht.' }
|
|
||||||
|
|
||||||
const zeitblock = await db.zeitblock.create({
|
|
||||||
data: {
|
|
||||||
aufgabeId: aufgabe.id,
|
|
||||||
kalenderkontoId: konto.id,
|
|
||||||
beginn: gelesen.data.beginn,
|
|
||||||
ende: gelesen.data.ende,
|
|
||||||
},
|
},
|
||||||
select: { id: true },
|
)
|
||||||
})
|
|
||||||
|
|
||||||
// Eine eingeplante Aufgabe ist geplant, nicht mehr bloß offen.
|
if (!ergebnis.ok) return ergebnis
|
||||||
if (aufgabe.status === 'OFFEN') {
|
|
||||||
await db.aufgabe.update({ where: { id: aufgabe.id }, data: { status: 'GEPLANT' } })
|
|
||||||
}
|
|
||||||
|
|
||||||
let schreibfehler: string | null = null
|
|
||||||
try {
|
|
||||||
const token = await gueltigesTokenFuer(konto.id, jetzt())
|
|
||||||
const ergebnis = await terminAnlegen({
|
|
||||||
anbieter: konto.anbieter,
|
|
||||||
kalenderId: konto.kalenderId,
|
|
||||||
token,
|
|
||||||
betreff: betreffFuer(aufgabe.titel),
|
|
||||||
beschreibung: aufgabe.notiz,
|
|
||||||
beginn: gelesen.data.beginn,
|
|
||||||
ende: gelesen.data.ende,
|
|
||||||
})
|
|
||||||
|
|
||||||
await db.zeitblock.update({
|
|
||||||
where: { id: zeitblock.id },
|
|
||||||
data: {
|
|
||||||
extId: ergebnis.extId,
|
|
||||||
etag: ergebnis.etag,
|
|
||||||
hash: zeitraumHash(gelesen.data.beginn, gelesen.data.ende),
|
|
||||||
letzterFehler: null,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch (fehler) {
|
|
||||||
schreibfehler =
|
|
||||||
fehler instanceof Error ? fehler.message : 'Der Termin ließ sich nicht anlegen.'
|
|
||||||
await db.zeitblock.update({
|
|
||||||
where: { id: zeitblock.id },
|
|
||||||
data: { letzterFehler: schreibfehler },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
await auditSchreiben({
|
|
||||||
akteur: { id: akteur.id, email: akteur.email },
|
|
||||||
aktion: 'zeitblock.angelegt',
|
|
||||||
objektTyp: 'Zeitblock',
|
|
||||||
objektId: zeitblock.id,
|
|
||||||
details: {
|
|
||||||
aufgabe: aufgabe.titel,
|
|
||||||
kalender: konto.anzeigename,
|
|
||||||
geschrieben: schreibfehler === null,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
revalidatePath('/woche')
|
revalidatePath('/woche')
|
||||||
revalidatePath('/aufgaben')
|
revalidatePath('/aufgaben')
|
||||||
revalidatePath('/')
|
revalidatePath('/')
|
||||||
|
|
||||||
return schreibfehler
|
return ergebnis.geschrieben
|
||||||
? {
|
? { ok: true, id: ergebnis.id, meldung: `„${ergebnis.titel}" eingeplant.` }
|
||||||
|
: {
|
||||||
ok: false,
|
ok: false,
|
||||||
fehler: `Eingeplant, aber nicht in den Kalender geschrieben: ${schreibfehler}`,
|
fehler: `Eingeplant, aber nicht in den Kalender geschrieben: ${ergebnis.fehler ?? 'unbekannter Fehler'}`,
|
||||||
}
|
}
|
||||||
: { ok: true, id: zeitblock.id, meldung: `„${aufgabe.titel}" eingeplant.` }
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Textfeld aus dem Formular.
|
||||||
|
*
|
||||||
|
* FormData kann auch Dateien liefern; die haetten hier nichts zu suchen und
|
||||||
|
* wuerden beim Stringify zu "[object Object]".
|
||||||
|
*/
|
||||||
|
function feld(daten: FormData, name: string): string {
|
||||||
|
const wert = daten.get(name)
|
||||||
|
return typeof wert === 'string' ? wert : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Verschieben -----------------------------------------------------------
|
// --- Verschieben -----------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,306 @@
|
|||||||
|
import 'server-only'
|
||||||
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { darf } from '@/lib/api'
|
||||||
|
import { alsDatum, alsDauer, alsUhrzeitAnzeige, wochenBeginn } from '@/lib/zeit'
|
||||||
|
import { auditSchreiben } from '@/server/audit'
|
||||||
|
import type { Aufrufer } from '@/server/api/token'
|
||||||
|
import { db } from '@/server/db'
|
||||||
|
import { zeitblockAnlegenFuer } from '@/server/zeitblock/dienst'
|
||||||
|
import { jetzt } from '@/server/zeitgeber'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der MCP-Server des Planers.
|
||||||
|
*
|
||||||
|
* Er hängt an denselben Tokens und denselben Rechten wie die Schnittstelle
|
||||||
|
* aus M7 — ein Sprachmodell bekommt keinen eigenen, weicheren Zugang. Was
|
||||||
|
* ein Token nicht darf, kann auch ein Werkzeug nicht.
|
||||||
|
*
|
||||||
|
* Die Werkzeuge sind bewusst wenige und grob geschnitten. Ein Modell, das
|
||||||
|
* zwischen zwanzig fast gleichen Werkzeugen wählen muss, wählt falsch; und
|
||||||
|
* jedes zusätzliche Werkzeug ist eine weitere Möglichkeit, versehentlich
|
||||||
|
* etwas zu ändern.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Antwort in der Form, die MCP erwartet. */
|
||||||
|
function text(inhalt: string) {
|
||||||
|
return { content: [{ type: 'text' as const, text: inhalt }] }
|
||||||
|
}
|
||||||
|
|
||||||
|
function fehler(inhalt: string) {
|
||||||
|
return { content: [{ type: 'text' as const, text: inhalt }], isError: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_OFFEN = ['OFFEN', 'GEPLANT', 'WARTET'] as const
|
||||||
|
|
||||||
|
/** Wie viele Datensätze ein Werkzeug höchstens zurückgibt. */
|
||||||
|
const GRENZE = 50
|
||||||
|
|
||||||
|
export function serverBauen(aufrufer: Aufrufer): McpServer {
|
||||||
|
const server = new McpServer(
|
||||||
|
{ name: 'planer', version: '1.0.0' },
|
||||||
|
{
|
||||||
|
instructions: [
|
||||||
|
'Der Planer verwaltet Aufgaben, Ideen, Projekte und Zeitblöcke einer',
|
||||||
|
'einzelnen Person. Aufgaben, die über diese Werkzeuge entstehen,',
|
||||||
|
'landen in der Inbox und werden von Hand einsortiert — setze also',
|
||||||
|
'keinen Bereich und kein Projekt, das geht hier ohnehin nicht.',
|
||||||
|
'Zeiten sind Europe/Berlin.',
|
||||||
|
].join(' '),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Lesen ---------------------------------------------------------------
|
||||||
|
|
||||||
|
if (darf(aufrufer.rechte, 'lesen')) {
|
||||||
|
server.registerTool(
|
||||||
|
'aufgaben_suchen',
|
||||||
|
{
|
||||||
|
title: 'Aufgaben suchen',
|
||||||
|
description:
|
||||||
|
'Listet Aufgaben. Ohne Angaben die offenen, nach Fälligkeit sortiert.',
|
||||||
|
inputSchema: {
|
||||||
|
suche: z.string().max(100).optional().describe('Text im Titel'),
|
||||||
|
nurOffene: z.boolean().default(true),
|
||||||
|
nurUeberfaellige: z.boolean().default(false),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ suche, nurOffene, nurUeberfaellige }) => {
|
||||||
|
const zeitpunkt = jetzt()
|
||||||
|
const aufgaben = await db.aufgabe.findMany({
|
||||||
|
where: {
|
||||||
|
typ: { not: 'IDEE' },
|
||||||
|
...(nurOffene ? { status: { in: [...STATUS_OFFEN] } } : {}),
|
||||||
|
...(nurUeberfaellige ? { faelligAm: { lt: zeitpunkt } } : {}),
|
||||||
|
...(suche ? { titel: { contains: suche, mode: 'insensitive' } } : {}),
|
||||||
|
},
|
||||||
|
orderBy: [{ faelligAm: { sort: 'asc', nulls: 'last' } }, { prioritaet: 'asc' }],
|
||||||
|
take: GRENZE,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
titel: true,
|
||||||
|
status: true,
|
||||||
|
prioritaet: true,
|
||||||
|
aufwandMinuten: true,
|
||||||
|
faelligAm: true,
|
||||||
|
bereich: { select: { name: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (aufgaben.length === 0) return text('Keine Aufgabe passt dazu.')
|
||||||
|
|
||||||
|
const zeilen = aufgaben.map((a) => {
|
||||||
|
const teile = [
|
||||||
|
a.titel,
|
||||||
|
`(${a.id})`,
|
||||||
|
a.status,
|
||||||
|
`Prio ${a.prioritaet}`,
|
||||||
|
a.aufwandMinuten ? alsDauer(a.aufwandMinuten) : 'ohne Schätzung',
|
||||||
|
a.faelligAm ? `fällig ${alsDatum(a.faelligAm)}` : 'ohne Termin',
|
||||||
|
a.bereich?.name ?? 'Inbox',
|
||||||
|
]
|
||||||
|
return `- ${teile.join(' · ')}`
|
||||||
|
})
|
||||||
|
return text(zeilen.join('\n'))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'woche_ansehen',
|
||||||
|
{
|
||||||
|
title: 'Woche ansehen',
|
||||||
|
description:
|
||||||
|
'Zeigt die eingeplanten Zeitblöcke einer Woche samt freier Kapazität.',
|
||||||
|
inputSchema: {
|
||||||
|
versatz: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(-52)
|
||||||
|
.max(52)
|
||||||
|
.default(0)
|
||||||
|
.describe('0 = diese Woche, 1 = nächste'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ versatz }) => {
|
||||||
|
const zeitpunkt = new Date(jetzt().getTime() + versatz * 7 * 86_400_000)
|
||||||
|
const von = wochenBeginn(zeitpunkt)
|
||||||
|
const bis = new Date(von.getTime() + 7 * 86_400_000)
|
||||||
|
|
||||||
|
const bloecke = await db.zeitblock.findMany({
|
||||||
|
where: {
|
||||||
|
kalenderkonto: { benutzerId: aufrufer.benutzerId },
|
||||||
|
beginn: { gte: von, lt: bis },
|
||||||
|
},
|
||||||
|
orderBy: { beginn: 'asc' },
|
||||||
|
select: {
|
||||||
|
beginn: true,
|
||||||
|
ende: true,
|
||||||
|
aufgabe: { select: { titel: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (bloecke.length === 0) {
|
||||||
|
return text(`Ab ${alsDatum(von)} ist noch nichts eingeplant.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const zeilen = bloecke.map(
|
||||||
|
(b) =>
|
||||||
|
`- ${alsDatum(b.beginn)} ${alsUhrzeitAnzeige(b.beginn)}–${alsUhrzeitAnzeige(b.ende)} ${b.aufgabe.titel}`,
|
||||||
|
)
|
||||||
|
const summe = bloecke.reduce(
|
||||||
|
(s, b) => s + (b.ende.getTime() - b.beginn.getTime()) / 60_000,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
return text(`${zeilen.join('\n')}\n\nZusammen ${alsDauer(summe)}.`)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'kalender_auflisten',
|
||||||
|
{
|
||||||
|
title: 'Kalender auflisten',
|
||||||
|
description:
|
||||||
|
'Die verbundenen Kalenderkonten. Ihre Kennung braucht das Einplanen.',
|
||||||
|
inputSchema: {},
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const konten = await db.kalenderkonto.findMany({
|
||||||
|
where: { benutzerId: aufrufer.benutzerId, aktiv: true },
|
||||||
|
orderBy: { erstelltAm: 'asc' },
|
||||||
|
select: { id: true, anzeigename: true, anbieter: true },
|
||||||
|
})
|
||||||
|
if (konten.length === 0) return text('Kein Kalender verbunden.')
|
||||||
|
return text(konten.map((k) => `- ${k.anzeigename} (${k.id})`).join('\n'))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Erfassen ------------------------------------------------------------
|
||||||
|
|
||||||
|
if (darf(aufrufer.rechte, 'erfassen')) {
|
||||||
|
server.registerTool(
|
||||||
|
'aufgabe_anlegen',
|
||||||
|
{
|
||||||
|
title: 'Aufgabe anlegen',
|
||||||
|
description:
|
||||||
|
'Legt eine Aufgabe in der Inbox an. Bereich und Projekt lassen sich nicht setzen.',
|
||||||
|
inputSchema: {
|
||||||
|
titel: z.string().trim().min(1).max(200),
|
||||||
|
notiz: z.string().trim().max(4000).optional(),
|
||||||
|
faelligAm: z.iso.date().optional().describe('JJJJ-MM-TT'),
|
||||||
|
aufwandMinuten: z.number().int().min(1).max(1440).optional(),
|
||||||
|
prioritaet: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(5)
|
||||||
|
.optional()
|
||||||
|
.describe('1 dringend … 5 irgendwann'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ titel, notiz, faelligAm, aufwandMinuten, prioritaet }) => {
|
||||||
|
const aufgabe = await db.aufgabe.create({
|
||||||
|
data: {
|
||||||
|
titel,
|
||||||
|
notiz: notiz ?? null,
|
||||||
|
quelle: 'MCP',
|
||||||
|
faelligAm: faelligAm ? new Date(faelligAm) : null,
|
||||||
|
...(aufwandMinuten ? { aufwandMinuten } : {}),
|
||||||
|
...(prioritaet ? { prioritaet } : {}),
|
||||||
|
},
|
||||||
|
select: { id: true, titel: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
await protokoll(aufrufer, 'mcp.erfasst', aufgabe.id, { titel: aufgabe.titel })
|
||||||
|
return text(`Angelegt: „${aufgabe.titel}" (${aufgabe.id}). Liegt in der Inbox.`)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Schreiben -----------------------------------------------------------
|
||||||
|
|
||||||
|
if (darf(aufrufer.rechte, 'schreiben')) {
|
||||||
|
server.registerTool(
|
||||||
|
'aufgabe_abhaken',
|
||||||
|
{
|
||||||
|
title: 'Aufgabe abhaken',
|
||||||
|
description: 'Setzt eine Aufgabe auf erledigt.',
|
||||||
|
inputSchema: { id: z.string().min(1) },
|
||||||
|
},
|
||||||
|
async ({ id }) => {
|
||||||
|
const aufgabe = await db.aufgabe.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { id: true, titel: true, status: true },
|
||||||
|
})
|
||||||
|
if (!aufgabe) return fehler('Diese Aufgabe gibt es nicht.')
|
||||||
|
if (aufgabe.status === 'ERLEDIGT') return text('War schon erledigt.')
|
||||||
|
|
||||||
|
await db.aufgabe.update({
|
||||||
|
where: { id: aufgabe.id },
|
||||||
|
data: { status: 'ERLEDIGT', erledigtAm: jetzt() },
|
||||||
|
})
|
||||||
|
await protokoll(aufrufer, 'mcp.abgehakt', aufgabe.id, { titel: aufgabe.titel })
|
||||||
|
return text(`Abgehakt: „${aufgabe.titel}".`)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'zeitblock_anlegen',
|
||||||
|
{
|
||||||
|
title: 'Aufgabe einplanen',
|
||||||
|
description:
|
||||||
|
'Legt einen Zeitblock für eine Aufgabe an und schreibt ihn in den Kalender.',
|
||||||
|
inputSchema: {
|
||||||
|
aufgabeId: z.string().min(1),
|
||||||
|
kalenderkontoId: z.string().min(1).describe('Kennung aus kalender_auflisten'),
|
||||||
|
beginn: z.iso.datetime().describe('ISO 8601 mit Zeitzone'),
|
||||||
|
ende: z.iso.datetime(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ aufgabeId, kalenderkontoId, beginn, ende }) => {
|
||||||
|
// Derselbe Weg wie beim Ziehen in der Wochenansicht: Rasterprüfung,
|
||||||
|
// Kalenderschreibweg, Schleifenschutz, Protokoll.
|
||||||
|
const ergebnis = await zeitblockAnlegenFuer(
|
||||||
|
{ id: aufrufer.benutzerId, email: aufrufer.email },
|
||||||
|
{
|
||||||
|
aufgabeId,
|
||||||
|
kalenderkontoId,
|
||||||
|
beginn: new Date(beginn),
|
||||||
|
ende: new Date(ende),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!ergebnis.ok) return fehler(ergebnis.fehler)
|
||||||
|
|
||||||
|
return text(
|
||||||
|
ergebnis.geschrieben
|
||||||
|
? `Eingeplant: „${ergebnis.titel}".`
|
||||||
|
: `Eingeplant, aber nicht in den Kalender geschrieben: ${ergebnis.fehler ?? 'unbekannter Fehler'}`,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hält fest, was über MCP passiert ist.
|
||||||
|
*
|
||||||
|
* Ein Sprachmodell, das Aufgaben anlegt, muss genauso nachvollziehbar sein
|
||||||
|
* wie ein Mensch — eher noch mehr.
|
||||||
|
*/
|
||||||
|
async function protokoll(
|
||||||
|
aufrufer: Aufrufer,
|
||||||
|
aktion: string,
|
||||||
|
objektId: string,
|
||||||
|
details: Record<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
await auditSchreiben({
|
||||||
|
akteur: { id: aufrufer.benutzerId, email: aufrufer.email },
|
||||||
|
aktion,
|
||||||
|
objektTyp: 'Aufgabe',
|
||||||
|
objektId,
|
||||||
|
details: { ueber: 'mcp', token: aufrufer.tokenId, ...details },
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import 'server-only'
|
||||||
|
import { zeitblockSchema, zeitraumHash } from '@/lib/zeitblock'
|
||||||
|
import { auditSchreiben } from '@/server/audit'
|
||||||
|
import { db } from '@/server/db'
|
||||||
|
import { gueltigesTokenFuer } from '@/server/kalender/abgleich'
|
||||||
|
import { terminAnlegen } from '@/server/kalender/schreiben'
|
||||||
|
import { jetzt } from '@/server/zeitgeber'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Das Einplanen selbst — ohne Sitzung, ohne Formular.
|
||||||
|
*
|
||||||
|
* Die Serveraktion aus der Oberfläche und der MCP-Server benutzen dieselbe
|
||||||
|
* Stelle. Zwei Umsetzungen desselben Vorgangs wären zwei Gelegenheiten, die
|
||||||
|
* Regeln unterschiedlich hinzuschreiben — und der Kalenderschreibweg samt
|
||||||
|
* Schleifenschutz ist nichts, was man zweimal richtig hinbekommt.
|
||||||
|
*
|
||||||
|
* Bewusst **nicht** in einer Datei mit `'use server'`: Dort wird jede
|
||||||
|
* exportierte Funktion zu einem Endpunkt, den der Browser aufrufen kann. Eine
|
||||||
|
* Funktion, die den Akteur als Parameter nimmt, wäre dann ein offenes Tor.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Akteur = { id: string; email: string }
|
||||||
|
|
||||||
|
export type Einplanung = {
|
||||||
|
aufgabeId: string
|
||||||
|
kalenderkontoId: string
|
||||||
|
beginn: Date
|
||||||
|
ende: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Einplanungsergebnis =
|
||||||
|
| { ok: true; id: string; titel: string; geschrieben: boolean; fehler: string | null }
|
||||||
|
| { ok: false; fehler: string }
|
||||||
|
|
||||||
|
/** Legt einen Zeitblock an und schreibt ihn in den Kalender. */
|
||||||
|
export async function zeitblockAnlegenFuer(
|
||||||
|
akteur: Akteur,
|
||||||
|
eingabe: Einplanung,
|
||||||
|
): Promise<Einplanungsergebnis> {
|
||||||
|
const gelesen = zeitblockSchema.safeParse(eingabe)
|
||||||
|
if (!gelesen.success) {
|
||||||
|
const lage = gelesen.error.issues.find((i) => i.path.includes('ende'))
|
||||||
|
return { ok: false, fehler: lage?.message ?? 'Die Angaben stimmen nicht.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const [aufgabe, konto] = await Promise.all([
|
||||||
|
db.aufgabe.findUnique({
|
||||||
|
where: { id: gelesen.data.aufgabeId },
|
||||||
|
select: { id: true, titel: true, notiz: true, status: true },
|
||||||
|
}),
|
||||||
|
db.kalenderkonto.findFirst({
|
||||||
|
where: { id: gelesen.data.kalenderkontoId, benutzerId: akteur.id, aktiv: true },
|
||||||
|
select: { id: true, anbieter: true, kalenderId: true, anzeigename: true },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!aufgabe) return { ok: false, fehler: 'Diese Aufgabe gibt es nicht.' }
|
||||||
|
if (!konto) return { ok: false, fehler: 'Dieses Kalenderkonto gibt es nicht.' }
|
||||||
|
|
||||||
|
const zeitblock = await db.zeitblock.create({
|
||||||
|
data: {
|
||||||
|
aufgabeId: aufgabe.id,
|
||||||
|
kalenderkontoId: konto.id,
|
||||||
|
beginn: gelesen.data.beginn,
|
||||||
|
ende: gelesen.data.ende,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Eine eingeplante Aufgabe ist geplant, nicht mehr bloß offen.
|
||||||
|
if (aufgabe.status === 'OFFEN') {
|
||||||
|
await db.aufgabe.update({ where: { id: aufgabe.id }, data: { status: 'GEPLANT' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
let schreibfehler: string | null = null
|
||||||
|
try {
|
||||||
|
const token = await gueltigesTokenFuer(konto.id, jetzt())
|
||||||
|
const ergebnis = await terminAnlegen({
|
||||||
|
anbieter: konto.anbieter,
|
||||||
|
kalenderId: konto.kalenderId,
|
||||||
|
token,
|
||||||
|
betreff: betreffFuer(aufgabe.titel),
|
||||||
|
beschreibung: aufgabe.notiz,
|
||||||
|
beginn: gelesen.data.beginn,
|
||||||
|
ende: gelesen.data.ende,
|
||||||
|
})
|
||||||
|
|
||||||
|
await db.zeitblock.update({
|
||||||
|
where: { id: zeitblock.id },
|
||||||
|
data: {
|
||||||
|
extId: ergebnis.extId,
|
||||||
|
etag: ergebnis.etag,
|
||||||
|
hash: zeitraumHash(gelesen.data.beginn, gelesen.data.ende),
|
||||||
|
letzterFehler: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (fehler) {
|
||||||
|
// Die Planung überlebt einen Netzhänger; der Zeitblock bleibt mit
|
||||||
|
// vermerktem Fehler stehen.
|
||||||
|
schreibfehler =
|
||||||
|
fehler instanceof Error ? fehler.message : 'Der Termin ließ sich nicht anlegen.'
|
||||||
|
await db.zeitblock.update({
|
||||||
|
where: { id: zeitblock.id },
|
||||||
|
data: { letzterFehler: schreibfehler },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await auditSchreiben({
|
||||||
|
akteur,
|
||||||
|
aktion: 'zeitblock.angelegt',
|
||||||
|
objektTyp: 'Zeitblock',
|
||||||
|
objektId: zeitblock.id,
|
||||||
|
details: {
|
||||||
|
aufgabe: aufgabe.titel,
|
||||||
|
kalender: konto.anzeigename,
|
||||||
|
geschrieben: schreibfehler === null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
id: zeitblock.id,
|
||||||
|
titel: aufgabe.titel,
|
||||||
|
geschrieben: schreibfehler === null,
|
||||||
|
fehler: schreibfehler,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der Betreff, unter dem ein Zeitblock im Kalender steht. */
|
||||||
|
export function betreffFuer(titel: string): string {
|
||||||
|
return titel
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user