589 lines
20 KiB
Go
589 lines
20 KiB
Go
package mcpserver
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"strings"
|
|
"time"
|
|
|
|
"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
|
|
|
|
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.
|
|
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)
|
|
|
|
jetzt := time.Now()
|
|
veraltet := 0
|
|
|
|
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
|
|
}
|
|
|
|
alt := e.Stale(jetzt)
|
|
if alt {
|
|
veraltet++
|
|
}
|
|
|
|
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"),
|
|
Stale: alt,
|
|
})
|
|
}
|
|
|
|
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."
|
|
// 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 veraltet > 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.", veraltet)
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
jetzt := time.Now()
|
|
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"),
|
|
Stale: e.Stale(jetzt),
|
|
})
|
|
}
|
|
|
|
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.Refresh(ctx, project.ID, geschrieben.Slug)
|
|
|
|
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.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
|
|
}
|
|
|
|
abstand := in.Distance
|
|
if abstand <= 0 {
|
|
abstand = relatedDefaultDistance
|
|
}
|
|
if abstand > relatedMaxDistance {
|
|
abstand = relatedMaxDistance
|
|
}
|
|
|
|
limit := in.Limit
|
|
if limit <= 0 {
|
|
limit = relatedDefaultLimit
|
|
}
|
|
if limit > relatedMaxLimit {
|
|
limit = relatedMaxLimit
|
|
}
|
|
|
|
verwandte, err := ws.Store.RelatedEntries(ctx, project.ID, in.Slug, abstand, 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
|
|
}
|
|
|
|
jetzt := time.Now()
|
|
items := make([]relatedItem, 0, len(verwandte))
|
|
for _, v := range verwandte {
|
|
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, jetzt),
|
|
})
|
|
}
|
|
|
|
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
|
|
},
|
|
)
|
|
}
|