munin/internal/mcpserver/server.go
Thomas Adamek cf35c3f5db Verwandte Einträge, Aktualität, Importer (Ausbaustufen 3.7, 3.5, 3.2)
- 3.7: MCP-Werkzeug "related" liefert Nachbarn im Verweisgraphen in beide
  Richtungen, bis zur zweiten Reihe (store.RelatedEntries).
- 3.5: Einträge, die seit über einem Jahr unverändert sind, tragen in
  recall/get/list/related "stale": true; get und recall geben einen
  Hinweis, die Eintragsseite eine Marke. instructions erklären das Feld.
- 3.2: Import von Markdown-Dateien als ZIP unter /p/{projekt}/import
  (Rolle admin), mit Vorschau, überspringen/überschreiben, Erkennung von
  Unverändertem und Papierkorb. Neue Einträge behalten Autor, Herkunft,
  Prüfvermerk und Zeitstempel aus dem Export.

Nebenbei behoben: Das 1-MB-Body-Limit für Formulare lag über dem ganzen
Mux und begrenzte damit auch /mcp auf 1 MB statt MAX_API_BODY_BYTES.
LimitBodies wählt die Grenze jetzt einmal außen je Route.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 08:18:47 +02:00

262 lines
11 KiB
Go

// Package mcpserver stellt Munin über das Model Context Protocol bereit —
// die Hauptschnittstelle dieses Projekts.
//
// # Was ist MCP?
//
// MCP ist ein offener Standard dafür, wie KI-Werkzeuge (Claude, Cline, OpenCode
// und eigene Anwendungen) mit externen Systemen sprechen. Statt dass jemand
// zwischen Wiki und Chatfenster hin- und herkopiert, ruft das Werkzeug hier
// direkt Funktionen auf.
//
// # Transport
//
// Verwendet wird "Streamable HTTP": Jede Anfrage ist ein gewöhnlicher HTTP-POST.
// Der ältere SSE-Transport hielt eine dauerhafte Verbindung offen, die von
// Proxys nach einiger Zeit getrennt wurde — die Verbindung brach dann ab, bis
// jemand das Werkzeug neu startete.
//
// # Abschottung
//
// Je Projekt gibt es einen eigenen MCP-Server, dessen Funktionen fest auf dieses
// eine Projekt verdrahtet sind. Ein Key kann also strukturell gar nicht an die
// Daten eines anderen Projekts gelangen — nicht, weil irgendwo richtig gefiltert
// wird, sondern weil der Weg dorthin nicht existiert.
package mcpserver
import (
"net/http"
"sync"
"github.com/modelcontextprotocol/go-sdk/mcp"
"munin/internal/auth"
"munin/internal/store"
"munin/internal/web"
)
// instructions erklären dem KI-Werkzeug, wie es diesen Server benutzen soll.
//
// Dieser Text ist das wirksamste Stellrad für die Qualität der Ergebnisse: Er
// steht bei jedem Client im Kontext und entscheidet darüber, ob ein Agent erst
// sucht und dann schreibt — oder blind Dubletten anlegt.
const instructions = `Dies ist Munin, der Wissensspeicher dieses Projekts — das
gemeinsame Gedächtnis von Menschen und KI-Agenten.
Vorgehen beim LESEN:
1. "recall" mit einer inhaltlichen Frage aufrufen (nicht mit Stichworten).
2. Liefert die Trefferliste einen passenden Eintrag, ihn mit "get" vollständig laden.
3. "list" gibt einen Überblick, wenn nicht klar ist, wonach zu suchen ist.
Vorgehen beim SCHREIBEN:
1. IMMER zuerst "recall" aufrufen, um zu prüfen, ob es zum Thema schon etwas gibt.
2. Gibt es einen passenden Eintrag: mit DESSEN Bezeichner (slug) "remember"
aufrufen — das aktualisiert ihn, statt eine zweite, widersprüchliche
Fassung daneben zu stellen.
3. Nur wenn es wirklich nichts Passendes gibt, einen neuen Bezeichner wählen.
Zum Bezeichner (slug):
Kurz, sprechend, dauerhaft: "deployment-prozess", nicht "notiz-2026-01-15".
Er ist der Schlüssel gegen Dubletten. Erlaubt sind a-z, 0-9, "-" und "_".
Verweise:
Im Inhalt verweist [[bezeichner]] auf einen anderen Eintrag. Verwandtes so zu
verknüpfen ist ausdrücklich erwünscht — es hilft beim späteren Wiederfinden.
"related" liefert zu einem Eintrag die verknüpften Einträge, auch solche, die
"recall" nicht findet, weil kein Suchwort passt.
Alter von Wissen:
Einträge mit "stale": true sind seit über einem Jahr unverändert. Sie können
noch stimmen, sollten aber nicht ungeprüft als aktueller Stand weitergegeben
werden. Wer weiß, dass sich etwas geändert hat, aktualisiert den Eintrag mit
"remember".
Gleichzeitiges Schreiben:
"get" liefert eine Feldnummer "version". Wird sie bei "remember" mitgegeben,
meldet der Server einen Konflikt, falls jemand anderes den Eintrag inzwischen
geändert hat, statt die fremde Änderung stillschweigend zu überschreiben.`
// Handler nimmt MCP-Anfragen entgegen und leitet sie an den Server des jeweiligen
// Projekts weiter.
//
// Anders als früher hält der Handler keine feste Datenbank mehr: web.APIAuth
// hat den Key bereits einem Workspace zugeordnet und dessen Store samt
// Suchindex im Context abgelegt (siehe web.WorkspaceFromContext). Jedes
// Werkzeug holt sich beides von dort — ein Handler kann so Anfragen für
// beliebig viele Workspaces gleichzeitig bedienen, statt an eine Datei
// gebunden zu sein.
type Handler struct {
// budget deckelt die schreibenden Aufrufe je Projekt. Darf nil sein — dann
// findet keine Begrenzung statt (praktisch für Tests, die damit nichts zu
// tun haben).
budget *auth.WriteBudget
mu sync.Mutex
servers map[string]*mcp.Server // Schlüssel: Projekt-ID
streamable http.Handler
}
// New erstellt den MCP-Handler. Die Server je Projekt entstehen beim ersten
// Zugriff (siehe serverFor).
func New(budget *auth.WriteBudget) *Handler {
h := &Handler{
budget: budget,
servers: make(map[string]*mcp.Server),
}
h.streamable = mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
project, ok := web.ProjectFrom(r)
if !ok {
return nil
}
return h.serverFor(project)
}, nil)
return h
}
// ServeHTTP beantwortet eine MCP-Anfrage.
//
// Die Authentifizierung ist zu diesem Zeitpunkt bereits erledigt: web.APIAuth hat
// den Key aufgelöst und Projekt samt Rolle im Context abgelegt.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if _, ok := web.ProjectFrom(r); !ok {
http.Error(w, "Kein Projekt aufgelöst", http.StatusUnauthorized)
return
}
h.streamable.ServeHTTP(w, r)
}
// serverFor gibt den MCP-Server eines Projekts zurück und baut ihn beim ersten
// Zugriff auf.
func (h *Handler) serverFor(project store.Project) *mcp.Server {
h.mu.Lock()
defer h.mu.Unlock()
if s, ok := h.servers[project.ID]; ok {
return s
}
s := h.buildServer(project)
h.servers[project.ID] = s
return s
}
// Forget wirft den Server eines Projekts weg — nach dessen Löschung.
func (h *Handler) Forget(projectID string) {
h.mu.Lock()
defer h.mu.Unlock()
delete(h.servers, projectID)
}
// ── Ein- und Ausgaben der Werkzeuge ──────────────────────────────────────────
type recallInput struct {
Query string `json:"query" jsonschema:"Inhaltliche Frage oder Suchbegriff"`
Tags []string `json:"tags,omitempty" jsonschema:"Optional: nur Einträge mit diesen Schlagworten"`
Limit int `json:"limit,omitempty" jsonschema:"Optional: Höchstzahl der Treffer (Standard 10, Maximum 50)"`
}
type recallMatch struct {
Slug string `json:"slug" jsonschema:"Bezeichner des Eintrags — für get und remember"`
Title string `json:"title" jsonschema:"Titel"`
Snippet string `json:"snippet" jsonschema:"Textstelle um den Treffer"`
Tags []string `json:"tags" jsonschema:"Schlagworte"`
Source string `json:"source" jsonschema:"human oder agent"`
Curated bool `json:"curated" jsonschema:"true, wenn ein Mensch den Eintrag geprüft hat"`
Updated string `json:"updated_at" jsonschema:"Zeitpunkt der letzten Änderung"`
Stale bool `json:"stale,omitempty" jsonschema:"true, wenn der Eintrag seit über einem Jahr unverändert ist — vor Verwendung prüfen"`
}
type recallOutput struct {
Results []recallMatch `json:"results" jsonschema:"Treffer, bester zuerst"`
Total int `json:"total" jsonschema:"Anzahl der Treffer"`
Hint string `json:"hint,omitempty" jsonschema:"Hinweis zum weiteren Vorgehen"`
}
type getInput struct {
Slug string `json:"slug" jsonschema:"Bezeichner des Eintrags"`
}
type getOutput struct {
Slug string `json:"slug"`
Title string `json:"title"`
Content string `json:"content" jsonschema:"Vollständiger Inhalt als Markdown"`
Tags []string `json:"tags"`
Source string `json:"source"`
Author string `json:"author" jsonschema:"Name des Zugangs, der zuletzt geschrieben hat"`
Curated bool `json:"curated"`
Version int `json:"version" jsonschema:"Für remember mitgeben, um Konflikte zu erkennen"`
Updated string `json:"updated_at"`
LinksTo []string `json:"links_to" jsonschema:"Bezeichner, auf die dieser Eintrag verweist"`
LinkedBy []string `json:"linked_by" jsonschema:"Bezeichner, die auf diesen Eintrag verweisen"`
Stale bool `json:"stale,omitempty" jsonschema:"true, wenn der Eintrag seit über einem Jahr unverändert ist"`
Hint string `json:"hint,omitempty" jsonschema:"Hinweis zum Umgang mit dem Eintrag"`
}
type rememberInput struct {
Slug string `json:"slug,omitempty" jsonschema:"Kurzer, dauerhafter Bezeichner (a-z, 0-9, - und _). Bei einem vorhandenen Eintrag wird dieser aktualisiert. Fehlt er, wird er aus dem Titel abgeleitet."`
Title string `json:"title" jsonschema:"Titel des Eintrags"`
Content string `json:"content" jsonschema:"Inhalt als Markdown. [[bezeichner]] verweist auf andere Einträge."`
Tags []string `json:"tags,omitempty" jsonschema:"Optional: Schlagworte zum Einordnen"`
ExpectedVersion int `json:"expected_version,omitempty" jsonschema:"Optional: die von get gelieferte Fassung — meldet einen Konflikt, wenn inzwischen jemand anderes geschrieben hat"`
}
type rememberOutput struct {
Slug string `json:"slug"`
Version int `json:"version" jsonschema:"Neue Fassung nach dem Schreiben"`
Created bool `json:"created" jsonschema:"true bei einem neuen Eintrag, false bei einer Aktualisierung"`
Message string `json:"message"`
}
type forgetInput struct {
Slug string `json:"slug" jsonschema:"Bezeichner des zu löschenden Eintrags"`
}
type forgetOutput struct {
Message string `json:"message"`
}
type listInput struct {
Tag string `json:"tag,omitempty" jsonschema:"Optional: nur Einträge mit diesem Schlagwort"`
Curated bool `json:"curated,omitempty" jsonschema:"Optional: nur von Menschen geprüfte Einträge"`
Limit int `json:"limit,omitempty" jsonschema:"Optional: Höchstzahl (Standard 50, Maximum 200)"`
}
type listItem struct {
Slug string `json:"slug"`
Title string `json:"title"`
Tags []string `json:"tags"`
Source string `json:"source"`
Curated bool `json:"curated"`
Updated string `json:"updated_at"`
Stale bool `json:"stale,omitempty"`
}
type listOutput struct {
Entries []listItem `json:"entries"`
Total int `json:"total" jsonschema:"Gesamtzahl der Einträge im Projekt"`
Tags []string `json:"tags" jsonschema:"Alle im Projekt vergebenen Schlagworte"`
}
type relatedInput struct {
Slug string `json:"slug" jsonschema:"Bezeichner des Eintrags, dessen Umfeld gesucht wird"`
Distance int `json:"distance,omitempty" jsonschema:"Optional: 1 = nur direkt verknüpfte Einträge, 2 = auch deren Nachbarn (Standard 2)"`
Limit int `json:"limit,omitempty" jsonschema:"Optional: Höchstzahl (Standard 20, Maximum 50)"`
}
type relatedItem struct {
Slug string `json:"slug"`
Title string `json:"title"`
Distance int `json:"distance" jsonschema:"1 = direkt verknüpft, 2 = über einen Zwischeneintrag"`
Via string `json:"via" jsonschema:"Eintrag, über den dieser gefunden wurde"`
Relation string `json:"relation" jsonschema:"links_to: via verweist hierhin; linked_by: dieser Eintrag verweist auf via"`
Updated string `json:"updated_at"`
Stale bool `json:"stale,omitempty"`
}
type relatedOutput struct {
Results []relatedItem `json:"results" jsonschema:"Verwandte Einträge, nächste zuerst"`
Total int `json:"total"`
Hint string `json:"hint,omitempty"`
}