227 lines
8.7 KiB
Go
227 lines
8.7 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/search"
|
|
"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.
|
|
type Handler struct {
|
|
store *store.Store
|
|
search *search.Registry
|
|
|
|
// 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(s *store.Store, idx *search.Registry, budget *auth.WriteBudget) *Handler {
|
|
h := &Handler{
|
|
store: s,
|
|
search: idx,
|
|
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"`
|
|
}
|