- 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>
262 lines
11 KiB
Go
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"`
|
|
}
|