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) }) })