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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user