munin/internal/mcpserver/tools.go
Thomas Adamek 466f1e40af Workspaces, Änderungshistorie, Papierkorb, Nutzungsübersicht, Hilfe
Umsetzung der Ausbaustufen 2.1, 3.6, 3.8, 3.9, 4.1, 4.2, 4.4, 4.6,
5.1–5.4 (Details in AUSBAUSTUFEN.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:44:52 +02:00

482 lines
16 KiB
Go

package mcpserver
import (
"context"
"errors"
"fmt"
"log"
"strings"
"github.com/modelcontextprotocol/go-sdk/mcp"
"munin/internal/auth"
"munin/internal/entry"
"munin/internal/store"
"munin/internal/web"
"munin/internal/workspace"
)
// workspaceFrom holt den Workspace (Datenbank + Suchindex) des aktuellen
// Aufrufs aus dem Context.
//
// web.APIAuth hat ihn dort abgelegt, nachdem es den Bearer-Key aufgelöst hat
// (siehe internal/web/middleware.go). Fehlt er, ist der Server falsch
// verdrahtet — MCP-Aufrufe laufen nie ohne durchlaufenes APIAuth hier an.
func workspaceFrom(ctx context.Context) (*workspace.Workspace, error) {
ws, ok := web.WorkspaceFromContext(ctx)
if !ok {
return nil, errors.New("kein Workspace aufgelöst")
}
return ws, nil
}
// Grenzen für die Werkzeuge.
//
// Die Obergrenzen schützen nicht den Server, sondern den Client: Eine Antwort mit
// 500 Einträgen sprengt das Kontextfenster des Modells und macht die Antwort
// schlechter, nicht besser.
const (
recallDefaultLimit = 10
recallMaxLimit = 50
listDefaultLimit = 50
listMaxLimit = 200
)
// buildServer erstellt den MCP-Server eines Projekts.
//
// Alle Werkzeuge sind Closures über dieses eine Projekt — sie können gar nicht
// erst auf ein anderes zugreifen.
func (h *Handler) buildServer(project store.Project) *mcp.Server {
server := mcp.NewServer(
&mcp.Implementation{Name: "munin", Version: "2.0.0"},
&mcp.ServerOptions{Instructions: instructions},
)
h.addRecall(server, project)
h.addGet(server, project)
h.addList(server, project)
h.addRemember(server, project)
h.addForget(server, project)
return server
}
// requireWrite prüft, ob dieser Aufruf schreiben darf — und verbucht ihn im
// Schreibbudget des Projekts.
//
// Zwei Fragen, eine Stelle: Die Rolle sagt, ob der Zugang GRUNDSÄTZLICH schreiben
// darf, das Budget, ob das Projekt gerade noch DARF. Beide Prüfungen gehören
// zusammen, weil sie sonst an jedem schreibenden Werkzeug einzeln stehen müssten
// und irgendwann eine davon vergessen wird.
//
// Die Rolle steckt im Context, den web.APIAuth gefüllt hat. Ein Werkzeug bei
// fehlender Berechtigung ganz auszublenden wäre möglich, wäre aber verwirrender:
// Das Modell sähe je nach Key unterschiedliche Werkzeuge und könnte nicht
// erklären, warum etwas nicht geht.
func (h *Handler) requireWrite(ctx context.Context) error {
sess, ok := web.SessionFromContext(ctx)
if !ok {
return errors.New("kein gültiger Zugang")
}
if !sess.Role.CanWrite() {
return fmt.Errorf("dieser Zugang hat nur Leserecht (Rolle %q) — zum Schreiben wird %q oder %q benötigt",
sess.Role, auth.RoleWrite, auth.RoleAdmin)
}
if h.budget == nil {
return nil
}
// Die Obergrenze kommt aus dem Projekt der Sitzung, nicht aus einer eigenen
// Abfrage: web.APIAuth hat die Projektzeile für DIESEN Request gerade eben
// frisch aus der Datenbank geholt. Der Wert ist damit aktuell, und der
// Schreibpfad kostet keine zusätzliche Abfrage.
erlaubt, retryAfter, ueberschreitung := h.budget.Consume(sess.Project.ID, sess.Project.WriteLimitHourly)
if erlaubt {
return nil
}
// Nur die erste Ablehnung je Fenster vermerken — sonst schriebe eine
// Schleife im Agenten das Protokoll voll, statt die Einträge.
if ueberschreitung == 1 {
log.Printf("Schreibbudget erschöpft: Projekt %q (%s), Zugang %q — weitere Schreibversuche werden bis zum Fensterende abgelehnt",
sess.Project.Slug, sess.Project.ID, sess.Key.Name)
}
// Die Meldung geht an ein Sprachmodell, nicht an einen Menschen. Deshalb
// steht darin ausdrücklich, was es TUN soll: warten, nicht neu versuchen und
// nicht auf ein anderes Werkzeug ausweichen.
return fmt.Errorf(
"Schreibbudget des Projekts erschöpft. In %d Minuten ist wieder Kontingent frei. "+
"NICHT sofort erneut versuchen und nicht auf andere Werkzeuge ausweichen — "+
"stattdessen die Aufgabe abbrechen und melden, dass das Schreiblimit erreicht ist",
int(retryAfter.Minutes())+1)
}
// actorName gibt den Namen des benutzten Zugangs zurück — für Herkunft und Protokoll.
func actorName(ctx context.Context) string {
if sess, ok := web.SessionFromContext(ctx); ok {
return sess.Key.Name
}
return "unbekannt"
}
// ── recall ───────────────────────────────────────────────────────────────────
func (h *Handler) addRecall(server *mcp.Server, project store.Project) {
mcp.AddTool(server,
&mcp.Tool{
Name: "recall",
Description: "Durchsucht das Gedächtnis dieses Projekts. IMMER zuerst aufrufen — " +
"sowohl vor dem Beantworten einer Frage als auch vor dem Schreiben, um Dubletten zu vermeiden.",
},
func(ctx context.Context, _ *mcp.CallToolRequest, in recallInput) (*mcp.CallToolResult, recallOutput, error) {
ws, err := workspaceFrom(ctx)
if err != nil {
return nil, recallOutput{}, err
}
if strings.TrimSpace(in.Query) == "" {
return nil, recallOutput{}, errors.New("query darf nicht leer sein")
}
limit := in.Limit
if limit <= 0 {
limit = recallDefaultLimit
}
if limit > recallMaxLimit {
limit = recallMaxLimit
}
idx, err := ws.Search.For(ctx, project.ID)
if err != nil {
return nil, recallOutput{}, err
}
// Etwas großzügiger suchen als am Ende ausgegeben wird: Die
// Schlagwortfilterung unten entfernt womöglich noch Treffer.
treffer := idx.Search(in.Query, limit*3)
gesucht := store.NormalizeTags(in.Tags)
ergebnisse := make([]recallMatch, 0, limit)
for _, t := range treffer {
if len(ergebnisse) >= limit {
break
}
e, err := ws.Store.EntryBySlug(ctx, project.ID, t.DocID)
if errors.Is(err, store.ErrNotFound) {
continue // Index kennt einen Eintrag, den es nicht mehr gibt
}
if err != nil {
return nil, recallOutput{}, err
}
if !hatAlleTags(e.Tags, gesucht) {
continue
}
ergebnisse = append(ergebnisse, recallMatch{
Slug: e.Slug,
Title: e.Title,
Snippet: entry.Snippet(e.Content, in.Query),
Tags: e.Tags,
Source: e.Source,
Curated: e.Curated,
Updated: e.UpdatedAt.Format("2006-01-02 15:04"),
})
}
out := recallOutput{Results: ergebnisse, Total: len(ergebnisse)}
if len(ergebnisse) == 0 {
out.Hint = "Nichts gefunden. Mit \"list\" einen Überblick verschaffen, " +
"oder — falls etwas Neues festgehalten werden soll — mit \"remember\" einen Eintrag anlegen."
} else {
out.Hint = "Für den vollständigen Inhalt \"get\" mit dem slug aufrufen."
}
return nil, out, nil
},
)
}
// hatAlleTags prüft, ob alle gesuchten Schlagworte vorhanden sind.
func hatAlleTags(vorhanden, gesucht []string) bool {
if len(gesucht) == 0 {
return true
}
menge := make(map[string]bool, len(vorhanden))
for _, t := range vorhanden {
menge[t] = true
}
for _, g := range gesucht {
if !menge[g] {
return false
}
}
return true
}
// ── get ──────────────────────────────────────────────────────────────────────
func (h *Handler) addGet(server *mcp.Server, project store.Project) {
mcp.AddTool(server,
&mcp.Tool{
Name: "get",
Description: "Lädt einen Eintrag vollständig, samt seiner Verweise. " +
"Die gelieferte \"version\" bei einem späteren \"remember\" mitgeben.",
},
func(ctx context.Context, _ *mcp.CallToolRequest, in getInput) (*mcp.CallToolResult, getOutput, error) {
ws, err := workspaceFrom(ctx)
if err != nil {
return nil, getOutput{}, err
}
e, err := ws.Store.EntryBySlug(ctx, project.ID, in.Slug)
if errors.Is(err, store.ErrNotFound) {
return nil, getOutput{}, fmt.Errorf("kein Eintrag mit dem Bezeichner %q — mit \"recall\" nach dem Thema suchen", in.Slug)
}
if err != nil {
return nil, getOutput{}, err
}
rueck, err := ws.Store.Backlinks(ctx, project.ID, in.Slug)
if err != nil {
return nil, getOutput{}, err
}
linkedBy := make([]string, 0, len(rueck))
for _, b := range rueck {
linkedBy = append(linkedBy, b.Slug)
}
return nil, getOutput{
Slug: e.Slug,
Title: e.Title,
Content: e.Content,
Tags: e.Tags,
Source: e.Source,
Author: e.Author,
Curated: e.Curated,
Version: e.Version,
Updated: e.UpdatedAt.Format("2006-01-02 15:04"),
LinksTo: store.ExtractLinks(e.Content),
LinkedBy: linkedBy,
}, nil
},
)
}
// ── list ─────────────────────────────────────────────────────────────────────
func (h *Handler) addList(server *mcp.Server, project store.Project) {
mcp.AddTool(server,
&mcp.Tool{
Name: "list",
Description: "Gibt einen Überblick über das Gedächtnis: zuletzt geänderte Einträge " +
"und alle vergebenen Schlagworte. Nützlich, wenn unklar ist, wonach zu suchen wäre.",
},
func(ctx context.Context, _ *mcp.CallToolRequest, in listInput) (*mcp.CallToolResult, listOutput, error) {
ws, err := workspaceFrom(ctx)
if err != nil {
return nil, listOutput{}, err
}
limit := in.Limit
if limit <= 0 {
limit = listDefaultLimit
}
if limit > listMaxLimit {
limit = listMaxLimit
}
eintraege, err := ws.Store.ListEntries(ctx, project.ID, store.EntryFilter{
Tag: in.Tag,
Curated: in.Curated,
Limit: limit,
})
if err != nil {
return nil, listOutput{}, err
}
gesamt, err := ws.Store.CountEntries(ctx, project.ID)
if err != nil {
return nil, listOutput{}, err
}
tags, err := ws.Store.ListTags(ctx, project.ID)
if err != nil {
return nil, listOutput{}, err
}
items := make([]listItem, 0, len(eintraege))
for _, e := range eintraege {
items = append(items, listItem{
Slug: e.Slug,
Title: e.Title,
Tags: e.Tags,
Source: e.Source,
Curated: e.Curated,
Updated: e.UpdatedAt.Format("2006-01-02 15:04"),
})
}
tagNamen := make([]string, 0, len(tags))
for _, t := range tags {
tagNamen = append(tagNamen, t.Tag)
}
return nil, listOutput{Entries: items, Total: gesamt, Tags: tagNamen}, nil
},
)
}
// ── remember ─────────────────────────────────────────────────────────────────
func (h *Handler) addRemember(server *mcp.Server, project store.Project) {
mcp.AddTool(server,
&mcp.Tool{
Name: "remember",
Description: "Legt einen Eintrag an oder aktualisiert ihn. Der slug entscheidet: " +
"Ein vorhandener Eintrag wird überschrieben, kein neuer danebengestellt. " +
"VOR dem Aufruf immer \"recall\" verwenden, um einen passenden vorhandenen Eintrag zu finden.",
},
func(ctx context.Context, _ *mcp.CallToolRequest, in rememberInput) (*mcp.CallToolResult, rememberOutput, error) {
ws, err := workspaceFrom(ctx)
if err != nil {
return nil, rememberOutput{}, err
}
if err := h.requireWrite(ctx); err != nil {
return nil, rememberOutput{}, err
}
if strings.TrimSpace(in.Content) == "" {
return nil, rememberOutput{}, errors.New("content darf nicht leer sein")
}
slug := strings.TrimSpace(in.Slug)
if slug == "" {
// Kein Bezeichner mitgegeben — aus dem Titel ableiten, statt den
// Aufruf abzulehnen. Das Modell soll nicht an Formalien scheitern.
slug = store.Slugify(in.Title)
}
if slug == "" {
return nil, rememberOutput{}, errors.New("slug fehlt und ließ sich nicht aus dem Titel ableiten")
}
titel := strings.TrimSpace(in.Title)
if titel == "" {
titel = entry.TitleFromContent(in.Content)
}
geschrieben, err := ws.Store.PutEntry(ctx, store.EntryWrite{
ProjectID: project.ID,
Slug: slug,
Title: titel,
Content: in.Content,
Tags: in.Tags,
Source: store.SourceAgent,
Author: actorName(ctx),
ExpectedVersion: in.ExpectedVersion,
})
if errors.Is(err, store.ErrConflict) {
// Die Meldung des Stores nennt bereits die Fassungsnummern —
// hier kommt nur die Handlungsanweisung fürs Modell dazu.
return nil, rememberOutput{}, fmt.Errorf(
"%w. Mit \"get\" den aktuellen Stand laden, die eigenen Änderungen einarbeiten und erneut schreiben", err)
}
if err != nil {
return nil, rememberOutput{}, err
}
ws.Search.Update(project.ID, geschrieben)
sess, _ := web.SessionFromContext(ctx)
ws.Store.LogAudit(ctx, store.AuditEvent{
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: actorName(ctx),
Action: store.ActionEntryWrite, EntrySlug: slug, Source: store.SourceMCP,
Detail: store.EntryWriteDetail(geschrieben),
})
// "angelegt" oder "aktualisiert"? Für das Modell ist dieser
// Unterschied wichtig: Es soll merken, wenn es unabsichtlich etwas
// überschrieben hat. Maßgeblich ist die Fassung nach dem Schreiben
// (PutEntry vergibt beim Anlegen immer 1) — eine Nachfrage vorher
// könnte bei zwei gleichzeitigen Schreibern danebenliegen, und sie
// sähe einen Eintrag im Papierkorb nicht.
istNeu := geschrieben.Version == 1
meldung := fmt.Sprintf("Eintrag %q aktualisiert (Fassung %d).", slug, geschrieben.Version)
switch {
case istNeu:
meldung = fmt.Sprintf("Eintrag %q angelegt.", slug)
case geschrieben.RestoredFromTrash:
// Der Bezeichner gehörte einem gelöschten Eintrag. Das Modell
// konnte ihn vorher mit recall/get nicht sehen — es soll aber
// wissen, dass es gerade etwas Gelöschtes wiederbelebt hat.
meldung = fmt.Sprintf("Unter %q lag ein gelöschter Eintrag im Papierkorb. Er wurde zurückgeholt "+
"und mit deinem Inhalt überschrieben (Fassung %d); der gelöschte Stand steht im Verlauf. "+
"War das nicht beabsichtigt, den Menschen darauf hinweisen.", slug, geschrieben.Version)
ws.Store.LogAudit(ctx, store.AuditEvent{
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: actorName(ctx),
Action: store.ActionEntryRestore, EntrySlug: slug, Source: store.SourceMCP,
Detail: "durch Schreiben unter demselben Bezeichner",
})
}
return nil, rememberOutput{
Slug: slug,
Version: geschrieben.Version,
Created: istNeu,
Message: meldung,
}, nil
},
)
}
// ── forget ───────────────────────────────────────────────────────────────────
func (h *Handler) addForget(server *mcp.Server, project store.Project) {
mcp.AddTool(server,
&mcp.Tool{
Name: "forget",
// Die Beschreibung sagt ehrlich, dass Löschen nicht endgültig ist —
// verschweigt aber nicht, dass es trotzdem Arbeit für Menschen
// erzeugt. Ein Agent soll nicht leichtfertiger löschen, nur weil es
// einen Papierkorb gibt. Wiederherstellen kann er selbst nicht: Das
// ist Absicht (siehe AUSBAUSTUFEN.md 3.9).
Description: fmt.Sprintf("Löscht einen Eintrag: Er verschwindet sofort aus allen Antworten und liegt "+
"%d Tage im Papierkorb, wo nur ein Mensch ihn zurückholen kann. Nur verwenden, wenn Wissen "+
"nachweislich falsch oder überholt ist — Veraltetes lässt sich meist besser mit \"remember\" richtigstellen.",
store.TrashRetentionDays),
},
func(ctx context.Context, _ *mcp.CallToolRequest, in forgetInput) (*mcp.CallToolResult, forgetOutput, error) {
ws, err := workspaceFrom(ctx)
if err != nil {
return nil, forgetOutput{}, err
}
if err := h.requireWrite(ctx); err != nil {
return nil, forgetOutput{}, err
}
err = ws.Store.MoveEntryToTrash(ctx, project.ID, in.Slug, actorName(ctx))
if errors.Is(err, store.ErrNotFound) {
return nil, forgetOutput{}, fmt.Errorf("kein Eintrag mit dem Bezeichner %q", in.Slug)
}
if err != nil {
return nil, forgetOutput{}, err
}
ws.Search.Delete(project.ID, in.Slug)
sess, _ := web.SessionFromContext(ctx)
ws.Store.LogAudit(ctx, store.AuditEvent{
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: actorName(ctx),
Action: store.ActionEntryDelete, EntrySlug: in.Slug, Source: store.SourceMCP,
Detail: "in den Papierkorb",
})
return nil, forgetOutput{Message: fmt.Sprintf(
"Eintrag %q gelöscht. Er liegt %d Tage im Papierkorb; ein Mensch kann ihn dort wiederherstellen.",
in.Slug, store.TrashRetentionDays)}, nil
},
)
}