Files
planer/e2e/mcp.spec.ts
T
Florian WildeandClaude Opus 5 bdc4260d59 feat: MCP-Server ueber denselben Token wie die API
Sechs Werkzeuge: Aufgaben suchen, Woche ansehen, Kalender auflisten,
Aufgabe anlegen, abhaken und einplanen. Welche davon ein Modell zu sehen
bekommt, haengt an den Rechten des Tokens — was nicht erlaubt ist, taucht
gar nicht erst in der Liste auf. Ein Sprachmodell bekommt keinen eigenen,
weicheren Zugang.

Bewusst wenige und grob geschnittene Werkzeuge. Ein Modell, das zwischen
zwanzig fast gleichen waehlen muss, waehlt falsch; und jedes weitere ist
eine zusaetzliche Moeglichkeit, versehentlich etwas zu aendern.

Zustandslos: Jede Anfrage steht fuer sich. Der Planer hat keine
langlaufenden Vorgaenge, und eine Sitzungsverwaltung waere Zustand, den
mehrere Container nicht teilen koennten.

Jede schreibende Nutzung steht im Protokoll. Ein Modell, das Aufgaben
anlegt, muss genauso nachvollziehbar sein wie ein Mensch — eher mehr.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 14:31:19 +02:00

174 lines
6.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
})
})