Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d69eebd4d | ||
|
|
96e105fe92 | ||
|
|
0ccb21303d | ||
|
|
bdc4260d59 | ||
|
|
5f45dff1a7 | ||
|
|
e2642e51a7 |
@@ -6,6 +6,31 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
## [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
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
@@ -43,6 +43,8 @@ wird.
|
||||
Planer lässt sich als App auf dem Startbildschirm ablegen
|
||||
- **Öffentliche Schnittstelle** mit Tokens je Anwendung, Rechten je Token und
|
||||
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
|
||||
Passwort — je Instanz einzeln zuschaltbar
|
||||
- Rollen `Benutzer` und `Verwaltung`; jede schreibende Aktion wird
|
||||
@@ -51,9 +53,10 @@ wird.
|
||||
- Hell und Dunkel, Oberfläche durchgehend auf Deutsch
|
||||
- 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
|
||||
|
||||
|
||||
+14
-4
@@ -12,7 +12,7 @@ grünen Prüfungen, aktueller Dokumentation und einem Eintrag im DEVLOG.
|
||||
| [M5](#m5--benachrichtigungen) | Benachrichtigungen | erledigt |
|
||||
| [M6](#m6--integrationen) | Integrationen | 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
|
||||
|
||||
- [ ] MCP-Server über `@modelcontextprotocol/sdk`
|
||||
- [ ] Lesen und Anlegen von Aufgaben und Zeitblöcken
|
||||
- [ ] Rechte wie bei der API, jede Nutzung im Protokoll
|
||||
**Erledigt am 13.09.2026 mit Version 0.8.0.**
|
||||
|
||||
**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 |
|
||||
| M6 | claude | 733 | 12,22 |
|
||||
| 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;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-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
|
||||
|
||||
## 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 Sprachmodelle: [MCP-Server](../MCP.md).
|
||||
Zur Einrichtung einer neuen Instanz: [Schnellstart](../SCHNELLSTART.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",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"private": true,
|
||||
"description": "Aufgaben, Ideen, Projekte und Zeitblöcke mit Anbindung an Microsoft 365 und Google Kalender",
|
||||
"type": "module",
|
||||
@@ -42,6 +42,7 @@
|
||||
"@date-fns/tz": "1.5.0",
|
||||
"@dnd-kit/core": "6.3.1",
|
||||
"@dnd-kit/utilities": "3.2.2",
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"@prisma/adapter-pg": "7.10.0",
|
||||
"@prisma/client": "7.10.0",
|
||||
"@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 { z } from 'zod'
|
||||
import {
|
||||
zeitblockSchema,
|
||||
zeitraumHash,
|
||||
LAGE_FEHLER_TEXT,
|
||||
lagePruefen,
|
||||
} from '@/lib/zeitblock'
|
||||
import { zeitraumHash, LAGE_FEHLER_TEXT, lagePruefen } from '@/lib/zeitblock'
|
||||
import { auditSchreiben } from '@/server/audit'
|
||||
import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung'
|
||||
import { db } from '@/server/db'
|
||||
@@ -18,6 +13,7 @@ import {
|
||||
terminAnlegen,
|
||||
terminEntfernen,
|
||||
} from '@/server/kalender/schreiben'
|
||||
import { betreffFuer, zeitblockAnlegenFuer } from '@/server/zeitblock/dienst'
|
||||
import { jetzt } from '@/server/zeitgeber'
|
||||
|
||||
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 ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -76,99 +67,39 @@ export async function zeitblockAnlegen(daten: FormData): Promise<Rueckmeldung> {
|
||||
if ('antwort' in geprueft) return geprueft.antwort
|
||||
const { akteur } = geprueft
|
||||
|
||||
const gelesen = zeitblockSchema.safeParse({
|
||||
aufgabeId: daten.get('aufgabeId'),
|
||||
kalenderkontoId: daten.get('kalenderkontoId'),
|
||||
beginn: daten.get('beginn'),
|
||||
ende: daten.get('ende'),
|
||||
})
|
||||
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,
|
||||
const ergebnis = await zeitblockAnlegenFuer(
|
||||
{ id: akteur.id, email: akteur.email },
|
||||
{
|
||||
aufgabeId: feld(daten, 'aufgabeId'),
|
||||
kalenderkontoId: feld(daten, 'kalenderkontoId'),
|
||||
beginn: new Date(feld(daten, 'beginn')),
|
||||
ende: new Date(feld(daten, '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) {
|
||||
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,
|
||||
},
|
||||
})
|
||||
if (!ergebnis.ok) return ergebnis
|
||||
|
||||
revalidatePath('/woche')
|
||||
revalidatePath('/aufgaben')
|
||||
revalidatePath('/')
|
||||
|
||||
return schreibfehler
|
||||
? {
|
||||
return ergebnis.geschrieben
|
||||
? { ok: true, id: ergebnis.id, meldung: `„${ergebnis.titel}" eingeplant.` }
|
||||
: {
|
||||
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 -----------------------------------------------------------
|
||||
|
||||
@@ -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