munin/internal/mcpserver/server.go

301 lines
14 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).
Die Suche vergleicht Wörter, nicht Bedeutungen. Deshalb unter "synonyms"
die Begriffe mitgeben, unter denen das Thema sonst noch heißen könnte:
Synonyme, die deutsche oder englische Übersetzung, Abkürzungen.
Beispiel: Frage nach dem Deployment → synonyms: release, rollout, auslieferung.
2. Liefert die Trefferliste einen passenden Eintrag, ihn mit "get" vollständig laden.
3. Findet "recall" nichts Passendes, mit anderen Begriffen erneut suchen,
bevor du annimmst, dass es nichts gibt.
4. "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.
Ähnelt der Titel einem vorhandenen Eintrag, legt "remember" nichts an,
sondern meldet "judgment_required" mit den "candidates". Passt einer,
dessen slug verwenden; ist es wirklich ein anderes Thema, erneut mit
"force": true aufrufen.
4. Am Ende des Inhalts eine Zeile mit Begriffen anfügen, unter denen jemand
den Eintrag suchen würde, die aber nicht im Text stehen:
Auch gesucht als: release, rollout, auslieferung
Synonyme, Übersetzungen, Abkürzungen — 2 bis 5 Begriffe genügen. Ist die
Zeile schon vorhanden, sie beim Aktualisieren erhalten und ergänzen.
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.
Kuratierte Einträge ("curated": true):
Ein Mensch hat sie geprüft. Sie haben Vorrang vor eigenen Annahmen und vor
ungeprüften Einträgen. Nur ändern, wenn sie nachweislich nicht mehr stimmen.
"remember" auf einen kuratierten Eintrag schreibt zunächst nicht, sondern
meldet "judgment_required". Ist die Änderung nötig: erneut mit
"override_curated": true und einer Begründung unter "reason". Die
Markierung fällt dann weg, und ein Mensch prüft die Änderung.
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"`
Synonyms []string `json:"synonyms,omitempty" jsonschema:"Optional, aber empfohlen: weitere Begriffe für dasselbe Thema — Synonyme, Übersetzung (deutsch/englisch), Abkürzungen. Werden in derselben Suche mitgesucht. Höchstens 10."`
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. Am Ende eine Zeile 'Auch gesucht als: …' mit Synonymen und Übersetzungen, die nicht im Text stehen."`
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"`
OverrideCurated bool `json:"override_curated,omitempty" jsonschema:"Optional: true ändert einen kuratierten (von Menschen geprüften) Eintrag. Nur, wenn er nachweislich nicht mehr stimmt — mit Begründung unter reason."`
Reason string `json:"reason,omitempty" jsonschema:"Pflicht bei override_curated: kurz, warum der geprüfte Stand nicht mehr stimmt. Ein Mensch liest das."`
Force bool `json:"force,omitempty" jsonschema:"Optional: true legt den Eintrag an, obwohl es ähnliche gibt. Nur verwenden, nachdem die gemeldeten candidates geprüft wurden und keiner passt."`
}
// dublettenKandidat ist ein vorhandener Eintrag, der einem neu anzulegenden
// ähnelt (siehe pruefeDubletten).
type dublettenKandidat struct {
Slug string `json:"slug"`
Title string `json:"title"`
Overlap int `json:"overlap" jsonschema:"Übereinstimmung der Titel in Prozent"`
}
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"`
// Nur gesetzt, wenn NICHT geschrieben wurde, weil es ähnliche Einträge gibt.
JudgmentRequired bool `json:"judgment_required,omitempty" jsonschema:"true: nichts geschrieben — erst die Meldung lesen und entscheiden"`
Candidates []dublettenKandidat `json:"candidates,omitempty" jsonschema:"Vorhandene Einträge, die dem neuen ähneln"`
}
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"`
}