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" ) // 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) { 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 := h.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 := h.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) { e, err := h.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 := h.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) { limit := in.Limit if limit <= 0 { limit = listDefaultLimit } if limit > listMaxLimit { limit = listMaxLimit } eintraege, err := h.store.ListEntries(ctx, project.ID, store.EntryFilter{ Tag: in.Tag, Curated: in.Curated, Limit: limit, }) if err != nil { return nil, listOutput{}, err } gesamt, err := h.store.CountEntries(ctx, project.ID) if err != nil { return nil, listOutput{}, err } tags, err := h.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) { 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) } // Vorher nachsehen, ob es den Eintrag schon gibt — nur um in der // Antwort "angelegt" von "aktualisiert" unterscheiden zu können. Für // das Modell ist dieser Unterschied wichtig: Es soll merken, wenn es // unabsichtlich etwas überschrieben hat. _, err := h.store.EntryBySlug(ctx, project.ID, slug) istNeu := errors.Is(err, store.ErrNotFound) geschrieben, err := h.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 } h.search.Update(project.ID, geschrieben) sess, _ := web.SessionFromContext(ctx) h.store.LogAudit(ctx, store.AuditEvent{ ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: actorName(ctx), Action: store.ActionEntryWrite, EntrySlug: slug, Source: store.SourceMCP, }) meldung := fmt.Sprintf("Eintrag %q aktualisiert (Fassung %d).", slug, geschrieben.Version) if istNeu { meldung = fmt.Sprintf("Eintrag %q angelegt.", slug) } 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", Description: "Löscht einen Eintrag endgültig. Nur verwenden, wenn Wissen nachweislich " + "falsch oder überholt ist — Veraltetes lässt sich meist besser mit \"remember\" richtigstellen.", }, func(ctx context.Context, _ *mcp.CallToolRequest, in forgetInput) (*mcp.CallToolResult, forgetOutput, error) { if err := h.requireWrite(ctx); err != nil { return nil, forgetOutput{}, err } err := h.store.DeleteEntry(ctx, project.ID, in.Slug) 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 } h.search.Delete(project.ID, in.Slug) sess, _ := web.SessionFromContext(ctx) h.store.LogAudit(ctx, store.AuditEvent{ ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: actorName(ctx), Action: store.ActionEntryDelete, EntrySlug: in.Slug, Source: store.SourceMCP, }) return nil, forgetOutput{Message: fmt.Sprintf("Eintrag %q gelöscht.", in.Slug)}, nil }, ) }