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
@@ -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