munin/internal/mcpserver/tools.go
2026-10-19 10:13:56 +02:00

703 lines
25 KiB
Go

package mcpserver
import (
"context"
"errors"
"fmt"
"log"
"strings"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
"munin/internal/auth"
"munin/internal/duplicates"
"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
recallMaxSynonyms = 10 // mehr bringt nichts, verwässert aber die Bewertung
listDefaultLimit = 50
listMaxLimit = 200
relatedDefaultLimit = 20
relatedMaxLimit = 50
relatedDefaultDistance = 2
// relatedMaxDistance ist bewusst klein. Schon in der dritten Reihe hängt
// in einem gut verknüpften Projekt fast alles mit allem zusammen, und die
// Antwort sagt nichts mehr über das Thema aus.
relatedMaxDistance = 2
)
// staleHint steht bei einem veralteten Eintrag in der Antwort von "get". Er
// sagt dem Modell, was es tun soll, nicht nur, was der Fall ist.
const staleHint = "Seit über einem Jahr unverändert. Vor Verwendung prüfen, ob das noch stimmt, " +
"und bei der Antwort erwähnen, dass der Stand alt ist. Ist bekannt, was sich geändert hat: mit \"remember\" aktualisieren."
// 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)
h.addRelated(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.
allowed, retryAfter, excess := h.budget.Consume(sess.Project.ID, sess.Project.WriteLimitHourly)
if allowed {
return nil
}
// Nur die erste Ablehnung je Fenster vermerken — sonst schriebe eine
// Schleife im Agenten das Protokoll voll, statt die Einträge.
if excess == 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. " +
"Die Suche vergleicht Wörter, keine Bedeutungen: unter \"synonyms\" weitere Begriffe " +
"für dasselbe Thema mitgeben (Synonyme, Übersetzung, Abkürzungen).",
},
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.
synonyms := limitSynonyms(in.Synonyms)
hits := idx.Search(withSynonyms(in.Query, synonyms), limit*3)
wanted := store.NormalizeTags(in.Tags)
now := time.Now()
stale := 0
results := make([]recallMatch, 0, limit)
for _, t := range hits {
if len(results) >= 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 !hasAllTags(e.Tags, wanted) {
continue
}
old := e.Stale(now)
if old {
stale++
}
results = append(results, recallMatch{
Slug: e.Slug,
Title: e.Title,
Snippet: entry.Snippet(e.Content, withSynonyms(in.Query, synonyms)),
Tags: e.Tags,
Source: e.Source,
Curated: e.Curated,
Updated: e.UpdatedAt.Format("2006-01-02 15:04"),
Stale: old,
})
}
// Ins Suchprotokoll, für die Nutzungsübersicht. WithoutCancel: Bricht
// der Client jetzt ab, ist die Suche trotzdem geschehen.
sess, _ := web.SessionFromContext(ctx)
ws.Store.LogSearch(context.WithoutCancel(ctx), store.SearchLogEntry{
ProjectID: project.ID, APIKeyID: sess.Key.ID,
Query: in.Query, Synonyms: synonyms, Results: len(results),
})
out := recallOutput{Results: results, Total: len(results)}
if len(results) == 0 {
out.Hint = "Nichts gefunden. Erst mit anderen Begriffen erneut suchen (Synonyme, Übersetzung, " +
"Abkürzungen unter \"synonyms\") oder mit \"list\" einen Überblick verschaffen. " +
"Erst wenn es wirklich nichts gibt und etwas Neues festgehalten werden soll, " +
"mit \"remember\" einen Eintrag anlegen."
} else {
out.Hint = "Für den vollständigen Inhalt \"get\" mit dem slug aufrufen."
// Veraltete Treffer bleiben in der Liste und rutschen auch
// nicht nach hinten: Ein alter Eintrag kann der einzige zum
// Thema sein. Der Hinweis sorgt dafür, dass das Modell das
// Feld "stale" nicht übersieht.
if stale > 0 {
out.Hint += fmt.Sprintf(" %d der Treffer sind seit über einem Jahr unverändert (\"stale\": true) "+
"— vor Verwendung prüfen, ob sie noch stimmen.", stale)
}
}
return nil, out, nil
},
)
}
// withSynonyms hängt die Synonyme an die Anfrage an, sodass sie in derselben
// Suche mitlaufen.
//
// # Warum so einfach?
//
// BM25 zählt jedes Suchwort für sich und addiert. Ein Eintrag, der das Thema
// unter einem anderen Namen führt („Release" statt „Deployment"), wird damit
// gefunden; einer, der beide Namen enthält, steht weiter oben. Genau das ist
// gewollt — eine eigene Gewichtung für Synonyme bräuchte es dafür nicht.
//
// Die Synonyme sollten vorher durch limitSynonyms gegangen sein.
func withSynonyms(query string, synonyms []string) string {
if len(synonyms) == 0 {
return query
}
return query + " " + strings.Join(synonyms, " ")
}
// limitSynonyms entfernt leere Angaben und kappt die Liste bei
// recallMaxSynonyms.
//
// Die Obergrenze verhindert, dass ein übereifriges Modell dreißig Begriffe
// mitschickt: Dann passt am Ende fast jeder Eintrag auf irgendeinen davon.
func limitSynonyms(synonyms []string) []string {
var clean []string
for _, s := range synonyms {
if s = strings.TrimSpace(s); s != "" {
clean = append(clean, s)
}
if len(clean) == recallMaxSynonyms {
break
}
}
return clean
}
// hasAllTags prüft, ob alle gesuchten Schlagworte vorhanden sind.
func hasAllTags(existing, wanted []string) bool {
if len(wanted) == 0 {
return true
}
set := make(map[string]bool, len(existing))
for _, t := range existing {
set[t] = true
}
for _, g := range wanted {
if !set[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
}
back, err := ws.Store.Backlinks(ctx, project.ID, in.Slug)
if err != nil {
return nil, getOutput{}, err
}
linkedBy := make([]string, 0, len(back))
for _, b := range back {
linkedBy = append(linkedBy, b.Slug)
}
out := 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,
}
if e.Stale(time.Now()) {
out.Stale = true
out.Hint = staleHint
}
return nil, out, 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
}
entries, err := ws.Store.ListEntries(ctx, project.ID, store.EntryFilter{
Tag: in.Tag,
Curated: in.Curated,
Limit: limit,
})
if err != nil {
return nil, listOutput{}, err
}
total, 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
}
now := time.Now()
items := make([]listItem, 0, len(entries))
for _, e := range entries {
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"),
Stale: e.Stale(now),
})
}
tagNames := make([]string, 0, len(tags))
for _, t := range tags {
tagNames = append(tagNames, t.Tag)
}
return nil, listOutput{Entries: items, Total: total, Tags: tagNames}, 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. " +
"Ähnelt ein NEUER Eintrag einem vorhandenen, wird nichts geschrieben: Die Antwort nennt " +
"\"candidates\" — einen davon fortschreiben oder mit \"force\": true doch neu anlegen. " +
"Kuratierte Einträge (von Menschen geprüft) ändert es nur mit \"override_curated\": true und \"reason\".",
},
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")
}
if err := checkJustification(in); err != nil {
return nil, rememberOutput{}, err
}
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")
}
title := strings.TrimSpace(in.Title)
if title == "" {
title = entry.TitleFromContent(in.Content)
}
// Gibt es schon einen ähnlichen Eintrag unter anderem Bezeichner?
// Dann nicht anlegen, sondern die Kandidaten zurückgeben (siehe
// internal/duplicates). Kein Fehler: Das Modell soll die Kandidaten als
// Daten lesen und entscheiden, nicht einen Fehlschlag behandeln.
if !in.Force {
found, err := duplicates.Check(ctx, ws, project, slug, title, in.Content)
if err != nil {
return nil, rememberOutput{}, err
}
candidates := make([]duplicateCandidate, 0, len(found))
for _, k := range found {
candidates = append(candidates, duplicateCandidate{
Slug: k.Slug, Title: k.Title, Overlap: k.Overlap, MatchedWords: k.MatchedWords,
})
}
if len(candidates) > 0 {
return nil, rememberOutput{
Slug: slug,
JudgmentRequired: true,
Candidates: candidates,
Message: "Nichts geschrieben: Es gibt bereits ähnliche Einträge (candidates). " +
"\"matched_words\" nennt die gemeinsamen Wörter. " +
"Passt einer davon, ihn mit \"get\" laden, den neuen Inhalt einarbeiten und " +
"mit DESSEN slug erneut \"remember\" aufrufen. Ist es wirklich ein anderes Thema " +
"(etwa weil nur ein allgemeines Wort übereinstimmt), " +
"denselben Aufruf mit \"force\": true wiederholen.",
}, nil
}
}
// Kuratierter Eintrag? Dann nur mit override_curated (siehe
// curated.go). Wie bei den Dubletten kein Fehler, sondern eine
// Meldung, die das Modell liest und auf die es entscheidet.
wasCurated, err := isCurated(ctx, ws, project, slug)
if err != nil {
return nil, rememberOutput{}, err
}
if wasCurated && !in.OverrideCurated {
return nil, rememberOutput{
Slug: slug,
JudgmentRequired: true,
Message: curatedHint,
}, nil
}
written, err := ws.Store.PutEntry(ctx, store.EntryWrite{
ProjectID: project.ID,
Slug: slug,
Title: title,
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.Refresh(ctx, project.ID, written.Slug)
// Hat der Agent einen kuratierten Eintrag geändert, steht das samt
// Begründung im Protokoll. Daraus baut die Nutzungsseite die Liste
// „bitte prüfen" (store.CuratedOverrides).
detail := store.EntryWriteDetail(written)
if wasCurated {
detail = store.CuratedOverrideDetail(in.Reason)
}
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: detail,
})
// "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.
isNew := written.Version == 1
message := fmt.Sprintf("Eintrag %q aktualisiert (Fassung %d).", slug, written.Version)
switch {
case wasCurated:
message = fmt.Sprintf("Eintrag %q aktualisiert (Fassung %d). Er war kuratiert; die Markierung "+
"ist aufgehoben, und ein Mensch prüft die Änderung. Der geprüfte Stand steht im Verlauf.",
slug, written.Version)
case isNew:
message = fmt.Sprintf("Eintrag %q angelegt.", slug)
case written.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.
message = 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, written.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: written.Version,
Created: isNew,
Message: message,
}, 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.Refresh(ctx, 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
},
)
}
// ── related ──────────────────────────────────────────────────────────────────
func (h *Handler) addRelated(server *mcp.Server, project store.Project) {
mcp.AddTool(server,
&mcp.Tool{
Name: "related",
Description: "Liefert die Einträge, die über [[Verweise]] mit einem Eintrag verknüpft sind — " +
"direkt und (Standard) auch über einen Zwischeneintrag. Findet Zusammenhänge, " +
"die \"recall\" nicht findet, weil kein Suchwort passt. Nützlich, nachdem \"get\" " +
"einen passenden Eintrag geliefert hat und mehr Kontext gebraucht wird.",
},
func(ctx context.Context, _ *mcp.CallToolRequest, in relatedInput) (*mcp.CallToolResult, relatedOutput, error) {
ws, err := workspaceFrom(ctx)
if err != nil {
return nil, relatedOutput{}, err
}
distance := in.Distance
if distance <= 0 {
distance = relatedDefaultDistance
}
if distance > relatedMaxDistance {
distance = relatedMaxDistance
}
limit := in.Limit
if limit <= 0 {
limit = relatedDefaultLimit
}
if limit > relatedMaxLimit {
limit = relatedMaxLimit
}
related, err := ws.Store.RelatedEntries(ctx, project.ID, in.Slug, distance, limit)
if errors.Is(err, store.ErrNotFound) {
return nil, relatedOutput{}, fmt.Errorf("kein Eintrag mit dem Bezeichner %q — mit \"recall\" nach dem Thema suchen", in.Slug)
}
if err != nil {
return nil, relatedOutput{}, err
}
now := time.Now()
items := make([]relatedItem, 0, len(related))
for _, v := range related {
items = append(items, relatedItem{
Slug: v.Slug,
Title: v.Title,
Distance: v.Distance,
Via: v.Via,
Relation: v.Relation,
Updated: v.UpdatedAt.Format("2006-01-02 15:04"),
Stale: store.StaleSince(v.UpdatedAt, now),
})
}
out := relatedOutput{Results: items, Total: len(items)}
if len(items) == 0 {
out.Hint = "Dieser Eintrag ist mit keinem anderen verknüpft. Mit \"recall\" nach verwandten " +
"Themen suchen. Findet sich etwas, lohnt es sich, die Einträge mit [[bezeichner]] zu verknüpfen."
} else {
out.Hint = "Für den vollständigen Inhalt \"get\" mit dem slug aufrufen."
}
return nil, out, nil
},
)
}