diff --git a/e2e/mcp.spec.ts b/e2e/mcp.spec.ts new file mode 100644 index 0000000..d62a9fd --- /dev/null +++ b/e2e/mcp.spec.ts @@ -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 { + 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 = {}, +) { + 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) + }) +}) diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts new file mode 100644 index 0000000..9b38df6 --- /dev/null +++ b/src/app/api/mcp/route.ts @@ -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 { + 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) +} diff --git a/src/server/mcp/werkzeuge.ts b/src/server/mcp/werkzeuge.ts new file mode 100644 index 0000000..95b474b --- /dev/null +++ b/src/server/mcp/werkzeuge.ts @@ -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, +): Promise { + await auditSchreiben({ + akteur: { id: aufrufer.benutzerId, email: aufrufer.email }, + aktion, + objektTyp: 'Aufgabe', + objektId, + details: { ueber: 'mcp', token: aufrufer.tokenId, ...details }, + }) +}