// 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. 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"` } 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"` } 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"` } 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"` }