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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
5f45dff1a7
commit
bdc4260d59
+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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user