681 lines
25 KiB
Go
681 lines
25 KiB
Go
// Dieses File implementiert einen MCP-Server (Model Context Protocol) für das Wiki.
|
|
//
|
|
// # Was ist MCP?
|
|
//
|
|
// MCP (Model Context Protocol) ist ein offener Standard der festlegt wie KI-Assistenten
|
|
// (Claude, Cline, Opencode, ...) mit externen Systemen kommunizieren. Statt dass der
|
|
// Nutzer copy-paste zwischen Wiki und Chat-Fenster macht, kann das KI-Tool direkt
|
|
// Seiten lesen, schreiben und löschen.
|
|
//
|
|
// # Wie funktioniert Streamable HTTP als Transport?
|
|
//
|
|
// MCP kann über verschiedene Transportwege laufen. Wir nutzen Streamable HTTP
|
|
// (MCP-Spezifikation 2025-11-25) — den modernen Standard.
|
|
//
|
|
// Der Ablauf ist einfach: jede MCP-Anfrage ist ein normaler HTTP POST-Request.
|
|
//
|
|
// 1. Das KI-Tool schickt POST /mcp mit einem JSON-RPC-Body:
|
|
// { "jsonrpc": "2.0", "method": "tools/call", "params": { "name": "search_pages", ... } }
|
|
//
|
|
// 2. Der Server antwortet direkt auf diesen POST — entweder als normales JSON
|
|
// oder als kurzer SSE-Stream wenn die Antwort gestreamt werden soll.
|
|
//
|
|
// Warum Streamable HTTP statt dem älteren SSE-Transport?
|
|
//
|
|
// Der ältere SSE-Transport hielt eine dauerhafte GET-Verbindung offen.
|
|
// Proxies und HTTP-Clients trennen solche Verbindungen nach einiger Zeit Inaktivität
|
|
// (Timeout) — die MCP-Verbindung bricht dann ab bis man das Tool neu startet.
|
|
// Streamable HTTP hat dieses Problem nicht: keine persistente Verbindung,
|
|
// kein Timeout, funktioniert zuverlässig auch nach längerer Pause.
|
|
//
|
|
// # Resources vs. Tools — der wichtige Unterschied
|
|
//
|
|
// MCP unterscheidet zwei Konzepte:
|
|
//
|
|
// - Resources: Dokumente und Daten die das KI-Tool lesen kann.
|
|
// Sie haben eine URI (wie eine Web-Adresse), einen MIME-Type und einen Inhalt.
|
|
// Das KI-Tool kann Resources direkt in seinen Kontext laden.
|
|
// Beispiel: wiki://pages (Seitenliste), wiki://page/projekte/notizen (eine Seite)
|
|
//
|
|
// - Tools: Aktionen die das KI-Tool ausführen kann — wie Funktionsaufrufe.
|
|
// Sie haben Parameter und geben ein Ergebnis zurück.
|
|
// Beispiel: upload_page, delete_page, search_pages
|
|
//
|
|
// Wiki-Seiten sind semantisch Resources (Daten zum Lesen), keine Tools (Aktionen).
|
|
// Deshalb: lesen/auflisten → Resources, schreiben/löschen/suchen → Tools.
|
|
//
|
|
// # Cache
|
|
//
|
|
// Alle lesenden Operationen nutzen den PageCache aus cache.go statt direkt
|
|
// vom Dateisystem zu lesen. So sind list und read schnell auch bei vielen Seiten.
|
|
// Schreibende Operationen (upload, delete) aktualisieren den Cache sofort.
|
|
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
"wiki/render"
|
|
)
|
|
|
|
// mcpActorLabel gibt "admin" oder "team" zurück, je nachdem womit der
|
|
// MCP-Request authentifiziert wurde — fürs Audit-Log.
|
|
func mcpActorLabel(ctx context.Context) string {
|
|
if IsAdminFromContext(ctx) {
|
|
return "admin"
|
|
}
|
|
return "team"
|
|
}
|
|
|
|
// ── Tool-Typen ────────────────────────────────────────────────────────────────
|
|
// Nur noch für Tools (Aktionen) — Resources brauchen keine eigenen Input/Output-Structs.
|
|
|
|
// uploadPageInput sind die Parameter für das upload_page-Tool.
|
|
type uploadPageInput struct {
|
|
Path string `json:"path" jsonschema:"Zielpfad inkl. .md, z.B. /projekte/notizen.md"`
|
|
Content string `json:"content" jsonschema:"Vollständiger Markdown-Inhalt inkl. Frontmatter"`
|
|
}
|
|
|
|
// uploadPageOutput bestätigt den erfolgreichen Upload.
|
|
type uploadPageOutput struct {
|
|
Message string `json:"message" jsonschema:"Bestätigungsmeldung"`
|
|
}
|
|
|
|
// deletePageInput ist der Parameter für das delete_page-Tool.
|
|
type deletePageInput struct {
|
|
Path string `json:"path" jsonschema:"Pfad zur Datei, z.B. /projekte/notizen.md"`
|
|
}
|
|
|
|
// deletePageOutput bestätigt das erfolgreiche Löschen.
|
|
type deletePageOutput struct {
|
|
Message string `json:"message" jsonschema:"Bestätigungsmeldung"`
|
|
}
|
|
|
|
// searchInput ist der Parameter für das search_pages-Tool.
|
|
type searchInput struct {
|
|
Query string `json:"query" jsonschema:"Suchbegriff — wird mit BM25-Ranking in Titel, Inhalt und Tags aller Seiten gesucht"`
|
|
}
|
|
|
|
// searchMatch ist ein einzelnes Suchergebnis.
|
|
type searchMatch struct {
|
|
Title string `json:"title" jsonschema:"Titel der Seite"`
|
|
URL string `json:"url" jsonschema:"URL der Seite"`
|
|
Snippet string `json:"snippet" jsonschema:"Textstelle um den Treffer herum"`
|
|
}
|
|
|
|
// searchOutput enthält alle Suchergebnisse, absteigend nach BM25-Relevanz sortiert.
|
|
type searchOutput struct {
|
|
Results []searchMatch `json:"results" jsonschema:"Gefundene Seiten, sortiert nach Relevanz (bester Treffer zuerst)"`
|
|
Total int `json:"total" jsonschema:"Anzahl Treffer"`
|
|
}
|
|
|
|
// rebuildIndexOutput ist die Bestätigung nach dem Aufbau des Wiki-Index.
|
|
type rebuildIndexOutput struct {
|
|
Message string `json:"message" jsonschema:"Bestätigungsmeldung"`
|
|
PageCount int `json:"page_count" jsonschema:"Anzahl der indizierten Seiten"`
|
|
}
|
|
|
|
// ── pageListEntry wird für die JSON-Ausgabe der wiki://pages Resource verwendet ─
|
|
|
|
type pageListEntry struct {
|
|
Title string `json:"title"`
|
|
URL string `json:"url"`
|
|
Tags []string `json:"tags"`
|
|
Protected bool `json:"protected"` // true wenn Passwortschutz aktiv
|
|
}
|
|
|
|
// ── MCPHandler ────────────────────────────────────────────────────────────────
|
|
|
|
// MCPHandler hält pro Team einen eigenen MCP-Server (jeweils mit Tool-Closures die
|
|
// nur auf den Cache/Docs-Ordner dieses Teams zugreifen) und stellt sie als
|
|
// gemeinsamen HTTP-Handler bereit.
|
|
//
|
|
// Team-Isolation: middleware.RequireTeamOrAdminKey legt das aufgelöste Team (oder
|
|
// den Admin-Status) bereits im Request-Context ab, bevor ServeHTTP hier läuft.
|
|
// Ein Team-Key kann also strukturell gar nicht an den Server eines anderen Teams
|
|
// geraten. Der globale Admin-Key muss das Zielteam explizit über den Header
|
|
// "X-Wiki-Team" angeben.
|
|
type MCPHandler struct {
|
|
registry *TeamRegistry
|
|
audit *AuditLogger
|
|
|
|
mu sync.Mutex
|
|
servers map[string]*mcp.Server // key: Team-ID — lazy befüllt, siehe getOrBuildServer
|
|
streamableHandler http.Handler
|
|
}
|
|
|
|
// NewMCPHandler erstellt den MCP-Handler. Die Team-Server selbst werden lazy
|
|
// gebaut (siehe getOrBuildServer) statt alle beim Start — so bekommen auch
|
|
// Teams die erst später per Hot-Reload von teams.yaml dazukommen (siehe
|
|
// TeamRegistry.Reload) automatisch einen funktionierenden MCP-Server, ohne
|
|
// dass der Prozess neu gestartet werden muss.
|
|
func NewMCPHandler(registry *TeamRegistry, audit *AuditLogger) *MCPHandler {
|
|
h := &MCPHandler{registry: registry, audit: audit, servers: make(map[string]*mcp.Server)}
|
|
|
|
// Streamable HTTP Transport statt SSE.
|
|
//
|
|
// SSE (Server-Sent Events) hält eine dauerhafte HTTP-Verbindung offen —
|
|
// Proxies und Clients trennen diese nach einiger Zeit Inaktivität (Timeout).
|
|
// Das führt dazu dass MCP-Verbindungen nach längerem Nichtbenutzen abbrechen.
|
|
//
|
|
// Streamable HTTP löst das: jede MCP-Anfrage ist ein normaler POST-Request,
|
|
// keine persistente Verbindung. Kein Timeout-Problem, moderner Standard (Spec 2025-11-25).
|
|
// Cline, Opencode und Claude Desktop unterstützen alle Streamable HTTP.
|
|
h.streamableHandler = mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
|
|
team, _ := TeamFromContext(r.Context())
|
|
if team == nil {
|
|
return nil
|
|
}
|
|
return h.getOrBuildServer(team)
|
|
}, nil)
|
|
|
|
return h
|
|
}
|
|
|
|
// getOrBuildServer gibt den MCP-Server eines Teams zurück und baut ihn beim
|
|
// ersten Zugriff auf. Bereits existierende Teams behalten ihren einmal gebauten
|
|
// Server (der auf team.Cache zeigt, das bei einem Reload für bestehende Teams
|
|
// wiederverwendet wird — siehe TeamRegistry.Reload), neue Teams bekommen einen frischen.
|
|
func (h *MCPHandler) getOrBuildServer(team *Team) *mcp.Server {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
|
|
if s, ok := h.servers[team.ID]; ok {
|
|
return s
|
|
}
|
|
|
|
s := buildTeamServer(team.ID, team.Cache, h.audit)
|
|
h.servers[team.ID] = s
|
|
return s
|
|
}
|
|
|
|
// ServeHTTP löst zunächst das Ziel-Team auf (aus dem Request-Context, den
|
|
// middleware.RequireTeamOrAdminKey vorher befüllt hat) und reicht den Request dann
|
|
// an den passenden Team-Server weiter.
|
|
func (h *MCPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
team, ok := TeamFromContext(r.Context())
|
|
isAdmin := IsAdminFromContext(r.Context())
|
|
|
|
if !ok {
|
|
if !isAdmin {
|
|
http.Error(w, "Ungültiger Key", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
// Admin-Key: das Zielteam muss explizit über den Header angegeben werden —
|
|
// anders als bei der Web-Ansicht gibt es hier keine Team-Umschaltung.
|
|
teamID := r.Header.Get("X-Wiki-Team")
|
|
t, found := h.registry.ByID(teamID)
|
|
if !found {
|
|
http.Error(w, "Header 'X-Wiki-Team' erforderlich (unbekanntes oder fehlendes Team) für Admin-Zugriff", http.StatusBadRequest)
|
|
return
|
|
}
|
|
team = t
|
|
}
|
|
|
|
// isAdmin bewusst durchreichen (nicht hart auf false setzen) — die Tool-Handler
|
|
// nutzen es fürs Audit-Log (mcpActorLabel), um "admin" von "team" zu unterscheiden.
|
|
h.streamableHandler.ServeHTTP(w, r.WithContext(WithTeam(r.Context(), team, isAdmin)))
|
|
}
|
|
|
|
// buildTeamServer erstellt einen MCP-Server mit allen Resources und Tools, dessen
|
|
// Closures ausschließlich auf den übergebenen (team-eigenen) Cache zugreifen.
|
|
func buildTeamServer(teamID string, cache *PageCache, audit *AuditLogger) *mcp.Server {
|
|
server := mcp.NewServer(&mcp.Implementation{
|
|
Name: "wiki",
|
|
Version: "1.0.0",
|
|
}, nil)
|
|
|
|
// ── Resource: wiki://pages ────────────────────────────────────────────────
|
|
// Listet alle Wiki-Seiten als JSON auf.
|
|
// Ein KI-Tool kann diese Resource laden um einen Überblick über das Wiki zu bekommen.
|
|
server.AddResource(
|
|
&mcp.Resource{
|
|
URI: "wiki://pages",
|
|
Name: "Wiki-Seitenverzeichnis",
|
|
Description: "Liste aller Wiki-Seiten mit Titel, URL und Tags als JSON.",
|
|
MIMEType: "application/json",
|
|
},
|
|
func(_ context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) {
|
|
// Alle Einträge aus dem Cache holen — kein Dateisystem-Zugriff nötig
|
|
entries := cache.All()
|
|
|
|
pages := make([]pageListEntry, 0, len(entries))
|
|
for _, entry := range entries {
|
|
// Versteckte Seiten werden nicht aufgelistet
|
|
if entry.Page.Hidden {
|
|
continue
|
|
}
|
|
pages = append(pages, pageListEntry{
|
|
Title: entry.Page.Title,
|
|
URL: entry.URLPath,
|
|
Tags: entry.Page.Tags,
|
|
Protected: entry.Page.Password != "",
|
|
})
|
|
}
|
|
|
|
// Zur JSON-Darstellung umwandeln.
|
|
// MarshalIndent formatiert das JSON lesbar mit Einrückung.
|
|
data, err := json.MarshalIndent(pages, "", " ")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Seitenliste konnte nicht serialisiert werden: %w", err)
|
|
}
|
|
|
|
return &mcp.ReadResourceResult{
|
|
Contents: []*mcp.ResourceContents{{
|
|
URI: req.Params.URI,
|
|
MIMEType: "application/json",
|
|
Text: string(data),
|
|
}},
|
|
}, nil
|
|
},
|
|
)
|
|
|
|
// ── ResourceTemplate: wiki://page/{+path} ─────────────────────────────────
|
|
// Stellt eine einzelne Wiki-Seite als Markdown-Resource bereit.
|
|
//
|
|
// URI-Template-Syntax: {+path} ist RFC 6570 "reserved expansion" —
|
|
// das + bedeutet dass Slashes im Pfad erlaubt sind (ohne + würden sie enkodiert).
|
|
// So matcht wiki://page/projekte/notizen mit path = "projekte/notizen".
|
|
//
|
|
// Beispiele:
|
|
// wiki://page/index → Startseite
|
|
// wiki://page/setup → /setup
|
|
// wiki://page/projekte/a → /projekte/a
|
|
server.AddResourceTemplate(
|
|
&mcp.ResourceTemplate{
|
|
URITemplate: "wiki://page/{+path}",
|
|
Name: "Wiki-Seite",
|
|
Description: "Liest eine Wiki-Seite als rohen Markdown-Inhalt. Pfad ohne führenden Slash, z.B. projekte/notizen",
|
|
MIMEType: "text/markdown",
|
|
},
|
|
func(_ context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) {
|
|
// Den Pfad aus der URI extrahieren.
|
|
// req.Params.URI ist z.B. "wiki://page/projekte/notizen"
|
|
path := strings.TrimPrefix(req.Params.URI, "wiki://page/")
|
|
|
|
// "index" als Sonderfall für die Startseite behandeln
|
|
urlPath := "/" + path
|
|
if path == "index" {
|
|
urlPath = "/"
|
|
}
|
|
|
|
filePath := cache.URLPathToFilePath(urlPath)
|
|
|
|
// Rohen Dateiinhalt lesen — wir wollen Markdown, nicht gerendertes HTML
|
|
rawBytes, err := os.ReadFile(filePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Seite nicht gefunden: %s", urlPath)
|
|
}
|
|
|
|
// Frontmatter entfernen damit das KI-Tool nur den Markdown-Body bekommt
|
|
markdownBody := stripFrontmatter(string(rawBytes))
|
|
|
|
// Titel aus dem Cache holen (schneller als nochmal Frontmatter parsen)
|
|
title := path
|
|
if page, ok := cache.Get(urlPath); ok {
|
|
title = page.Title
|
|
}
|
|
|
|
// Titel als H1-Überschrift voranstellen falls nicht schon im Body
|
|
content := fmt.Sprintf("# %s\n\n%s", title, markdownBody)
|
|
|
|
return &mcp.ReadResourceResult{
|
|
Contents: []*mcp.ResourceContents{{
|
|
URI: req.Params.URI,
|
|
MIMEType: "text/markdown",
|
|
Text: content,
|
|
}},
|
|
}, nil
|
|
},
|
|
)
|
|
|
|
// ── Tool: search_pages ────────────────────────────────────────────────────
|
|
// Durchsucht alle Wiki-Seiten mit BM25-Ranking.
|
|
//
|
|
// Gegenüber der früheren String-Suche (strings.Contains über alle Dateien) hat
|
|
// BM25 zwei Vorteile:
|
|
// 1. Geschwindigkeit: Der invertierte Index im Speicher wird genutzt — kein
|
|
// Dateisystem-Zugriff, keine lineare Schleife über alle Seiten.
|
|
// 2. Ranking: Ergebnisse sind nach Relevanz sortiert. Titel-Treffer erscheinen
|
|
// vor Body-Treffern; seltene, spezifische Begriffe wiegen mehr als häufige.
|
|
mcp.AddTool(server,
|
|
&mcp.Tool{
|
|
Name: "search_pages",
|
|
Description: "Durchsucht alle Wiki-Seiten mit BM25-Ranking — Titel-Treffer zuerst, dann Body. Ergebnisse sind nach Relevanz sortiert.",
|
|
},
|
|
func(_ context.Context, _ *mcp.CallToolRequest, in searchInput) (*mcp.CallToolResult, searchOutput, error) {
|
|
if in.Query == "" {
|
|
return nil, searchOutput{}, fmt.Errorf("query darf nicht leer sein")
|
|
}
|
|
|
|
// BM25-Suche über den In-Memory-Index — kein Dateisystem-Zugriff nötig
|
|
hits := cache.SearchIndex.Search(in.Query, 20)
|
|
|
|
matches := make([]searchMatch, 0, len(hits))
|
|
for _, hit := range hits {
|
|
page, ok := cache.Get(hit.URLPath)
|
|
if !ok || page.Hidden {
|
|
continue
|
|
}
|
|
|
|
// Snippet aus dem im Page gecacheten Rohtext extrahieren
|
|
snippet := extractSnippet(page.RawBody, in.Query)
|
|
|
|
matches = append(matches, searchMatch{
|
|
Title: page.Title,
|
|
URL: hit.URLPath,
|
|
Snippet: snippet,
|
|
})
|
|
}
|
|
|
|
return nil, searchOutput{Results: matches, Total: len(matches)}, nil
|
|
},
|
|
)
|
|
|
|
// ── Tool: rebuild_index ───────────────────────────────────────────────────
|
|
// Generiert eine strukturierte _index.md aus allen vorhandenen Wiki-Seiten.
|
|
//
|
|
// Inspiration: Karpathy's "LLM Wiki" Muster (https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f)
|
|
// Der Index dient KI-Tools als Navigationshilfe: statt blind durch einzelne Seiten
|
|
// zu suchen, kann ein KI-Tool zuerst den Index lesen um zu verstehen was im Wiki
|
|
// überhaupt vorhanden ist — nach Kategorien (Tags) gegliedert.
|
|
//
|
|
// Die _index.md wird als versteckte Seite gespeichert (hidden: true) — sie erscheint
|
|
// nicht in der Sidebar-Navigation, ist aber per MCP als Resource abrufbar:
|
|
// wiki://page/_index
|
|
//
|
|
// Empfohlener Workflow für KI-Tools:
|
|
// 1. rebuild_index aufrufen nachdem neue Seiten hinzugefügt wurden
|
|
// 2. wiki://page/_index lesen für einen Überblick über das Wiki
|
|
// 3. search_pages für spezifische Inhaltssuche nutzen
|
|
mcp.AddTool(server,
|
|
&mcp.Tool{
|
|
Name: "rebuild_index",
|
|
Description: "Generiert _index.md neu — eine strukturierte Übersicht aller Wiki-Seiten, gegliedert nach Tags/Kategorien. Wird nach jedem upload_page automatisch aufgerufen, kann aber auch manuell ausgelöst werden.",
|
|
},
|
|
func(_ context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, rebuildIndexOutput, error) {
|
|
out, err := buildWikiIndex(cache)
|
|
return nil, out, err
|
|
},
|
|
)
|
|
|
|
// ── Tool: upload_page ─────────────────────────────────────────────────────
|
|
// Erstellt eine neue Seite oder überschreibt eine bestehende.
|
|
// Nach dem Schreiben wird der Cache sofort aktualisiert und — sofern es sich
|
|
// nicht um _index.md selbst handelt — der Wiki-Index automatisch neu generiert.
|
|
mcp.AddTool(server,
|
|
&mcp.Tool{
|
|
Name: "upload_page",
|
|
Description: "Erstellt eine neue Wiki-Seite oder überschreibt eine bestehende. Inhalt muss vollständiges Markdown mit Frontmatter sein. Der _index.md wird danach automatisch aktualisiert.",
|
|
},
|
|
func(ctx context.Context, _ *mcp.CallToolRequest, in uploadPageInput) (*mcp.CallToolResult, uploadPageOutput, error) {
|
|
if in.Path == "" {
|
|
return nil, uploadPageOutput{}, fmt.Errorf("path darf nicht leer sein")
|
|
}
|
|
if in.Content == "" {
|
|
return nil, uploadPageOutput{}, fmt.Errorf("content darf nicht leer sein")
|
|
}
|
|
if strings.Contains(in.Path, "..") {
|
|
return nil, uploadPageOutput{}, fmt.Errorf("ungültiger Pfad")
|
|
}
|
|
|
|
fullPath := cache.URLPathToFilePath(in.Path)
|
|
|
|
if err := os.MkdirAll(strings.TrimSuffix(fullPath, "/"+lastSegment(fullPath)), 0755); err != nil {
|
|
return nil, uploadPageOutput{}, fmt.Errorf("Ordner konnte nicht erstellt werden: %w", err)
|
|
}
|
|
|
|
if err := os.WriteFile(fullPath, []byte(in.Content), 0644); err != nil {
|
|
return nil, uploadPageOutput{}, fmt.Errorf("Datei konnte nicht geschrieben werden: %w", err)
|
|
}
|
|
|
|
// Cache und Suchindex sofort aktualisieren
|
|
if page, err := render.LoadPage(fullPath); err == nil {
|
|
cache.Set(in.Path, page)
|
|
}
|
|
|
|
// _index.md automatisch neu aufbauen — außer wenn _index.md selbst
|
|
// hochgeladen wird (das würde eine Endlosschleife verursachen).
|
|
if in.Path != "/_index.md" {
|
|
buildWikiIndex(cache) //nolint:errcheck — Fehler beim Index-Rebuild sind nicht kritisch
|
|
}
|
|
|
|
audit.Log(AuditEvent{
|
|
Team: teamID,
|
|
Actor: mcpActorLabel(ctx),
|
|
Action: "upload",
|
|
Path: in.Path,
|
|
Source: "mcp",
|
|
})
|
|
|
|
return nil, uploadPageOutput{Message: fmt.Sprintf("Seite gespeichert: %s", in.Path)}, nil
|
|
},
|
|
)
|
|
|
|
// ── Tool: delete_page ─────────────────────────────────────────────────────
|
|
// Löscht eine Wiki-Seite und entfernt sie sofort aus dem Cache.
|
|
mcp.AddTool(server,
|
|
&mcp.Tool{
|
|
Name: "delete_page",
|
|
Description: "Löscht eine Wiki-Seite anhand ihres Pfads.",
|
|
},
|
|
func(ctx context.Context, _ *mcp.CallToolRequest, in deletePageInput) (*mcp.CallToolResult, deletePageOutput, error) {
|
|
if in.Path == "" {
|
|
return nil, deletePageOutput{}, fmt.Errorf("path darf nicht leer sein")
|
|
}
|
|
if strings.Contains(in.Path, "..") {
|
|
return nil, deletePageOutput{}, fmt.Errorf("ungültiger Pfad")
|
|
}
|
|
|
|
fullPath := cache.URLPathToFilePath(in.Path)
|
|
|
|
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
|
|
return nil, deletePageOutput{}, fmt.Errorf("Seite nicht gefunden: %s", in.Path)
|
|
}
|
|
|
|
if err := os.Remove(fullPath); err != nil {
|
|
return nil, deletePageOutput{}, fmt.Errorf("Seite konnte nicht gelöscht werden: %w", err)
|
|
}
|
|
|
|
// Cache-Eintrag entfernen
|
|
cache.Delete(in.Path)
|
|
|
|
audit.Log(AuditEvent{
|
|
Team: teamID,
|
|
Actor: mcpActorLabel(ctx),
|
|
Action: "delete",
|
|
Path: in.Path,
|
|
Source: "mcp",
|
|
})
|
|
|
|
return nil, deletePageOutput{Message: fmt.Sprintf("Seite gelöscht: %s", in.Path)}, nil
|
|
},
|
|
)
|
|
|
|
return server
|
|
}
|
|
|
|
// ── Hilfsfunktionen ───────────────────────────────────────────────────────────
|
|
|
|
// extractSnippet gibt ~100 Zeichen um den ersten Treffer von query in content zurück.
|
|
// Nützlich um dem KI-Tool Kontext zum Suchtreffer zu geben.
|
|
func extractSnippet(content, query string) string {
|
|
lowerContent := strings.ToLower(content)
|
|
lowerQuery := strings.ToLower(query)
|
|
|
|
idx := strings.Index(lowerContent, lowerQuery)
|
|
if idx == -1 {
|
|
// Kein Treffer im Inhalt — leeren String zurückgeben
|
|
return ""
|
|
}
|
|
|
|
const radius = 100 // Zeichen vor und nach dem Treffer
|
|
start := idx - radius
|
|
if start < 0 {
|
|
start = 0
|
|
}
|
|
end := idx + len(query) + radius
|
|
if end > len(content) {
|
|
end = len(content)
|
|
}
|
|
|
|
snippet := content[start:end]
|
|
|
|
// "..." hinzufügen wenn der Snippet nicht am Anfang/Ende des Texts beginnt
|
|
if start > 0 {
|
|
snippet = "..." + snippet
|
|
}
|
|
if end < len(content) {
|
|
snippet += "..."
|
|
}
|
|
|
|
return strings.TrimSpace(snippet)
|
|
}
|
|
|
|
// stripFrontmatter entfernt den YAML-Frontmatter-Block vom Anfang eines Markdown-Strings.
|
|
func stripFrontmatter(content string) string {
|
|
if !strings.HasPrefix(content, "---") {
|
|
return content
|
|
}
|
|
rest := strings.TrimPrefix(content, "---\n")
|
|
idx := strings.Index(rest, "---")
|
|
if idx == -1 {
|
|
return content
|
|
}
|
|
return strings.TrimSpace(rest[idx+4:])
|
|
}
|
|
|
|
// lastSegment gibt den letzten Teil eines Dateipfads zurück (nach dem letzten "/").
|
|
func lastSegment(path string) string {
|
|
parts := strings.Split(path, "/")
|
|
return parts[len(parts)-1]
|
|
}
|
|
|
|
// buildWikiIndex generiert _index.md aus allen aktuellen Wiki-Seiten und schreibt
|
|
// sie ins Dateisystem. Cache und Suchindex werden danach sofort aktualisiert.
|
|
//
|
|
// Die Funktion ist als eigenständige Hilfsfunktion ausgelagert damit sowohl
|
|
// das rebuild_index-Tool als auch upload_page sie aufrufen können, ohne Logik
|
|
// zu duplizieren.
|
|
func buildWikiIndex(cache *PageCache) (rebuildIndexOutput, error) {
|
|
entries := cache.All()
|
|
|
|
// Seiten nach erstem Tag gruppieren.
|
|
// Seiten ohne Tag landen in der Gruppe "Allgemein".
|
|
groups := make(map[string][]CachedEntry)
|
|
for _, entry := range entries {
|
|
// _index selbst nicht aufnehmen — sonst würde er sich selbst referenzieren
|
|
if entry.URLPath == "/_index" {
|
|
continue
|
|
}
|
|
|
|
category := "Allgemein"
|
|
if len(entry.Page.Tags) > 0 {
|
|
category = entry.Page.Tags[0]
|
|
}
|
|
groups[category] = append(groups[category], entry)
|
|
}
|
|
|
|
// Kategorien alphabetisch sortieren, "Allgemein" ans Ende
|
|
categories := make([]string, 0, len(groups))
|
|
for cat := range groups {
|
|
categories = append(categories, cat)
|
|
}
|
|
sortCategoriesWithFallbackLast(categories, "Allgemein")
|
|
|
|
// Markdown aufbauen
|
|
var sb strings.Builder
|
|
sb.WriteString("---\n")
|
|
sb.WriteString("title: Wiki Index\n")
|
|
sb.WriteString("hidden: true\n")
|
|
sb.WriteString("---\n\n")
|
|
sb.WriteString("Automatisch generierter Index aller Wiki-Seiten, gegliedert nach Kategorien.\n")
|
|
sb.WriteString("Wird bei jedem `upload_page` automatisch aktualisiert.\n\n")
|
|
|
|
totalPages := 0
|
|
for _, cat := range categories {
|
|
pages := groups[cat]
|
|
sortEntriesByTitle(pages)
|
|
|
|
sb.WriteString("## ")
|
|
sb.WriteString(cat)
|
|
sb.WriteString("\n\n")
|
|
|
|
for _, entry := range pages {
|
|
title := entry.Page.Title
|
|
if title == "" {
|
|
title = entry.URLPath
|
|
}
|
|
|
|
extraTags := filterOutFirst(entry.Page.Tags)
|
|
tagStr := ""
|
|
if len(extraTags) > 0 {
|
|
tagStr = " — Tags: " + strings.Join(extraTags, ", ")
|
|
}
|
|
|
|
protection := ""
|
|
if entry.Page.Password != "" {
|
|
protection = " 🔒"
|
|
}
|
|
|
|
sb.WriteString(fmt.Sprintf("- [%s](%s)%s%s\n", title, entry.URLPath, protection, tagStr))
|
|
totalPages++
|
|
}
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
// _index.md schreiben und Cache aktualisieren
|
|
fullPath := cache.URLPathToFilePath("/_index.md")
|
|
if err := os.WriteFile(fullPath, []byte(sb.String()), 0644); err != nil {
|
|
return rebuildIndexOutput{}, fmt.Errorf("_index.md konnte nicht geschrieben werden: %w", err)
|
|
}
|
|
|
|
if page, err := render.LoadPage(fullPath); err == nil {
|
|
cache.Set("/_index", page)
|
|
}
|
|
|
|
return rebuildIndexOutput{
|
|
Message: fmt.Sprintf("_index.md aktualisiert — %d Seiten in %d Kategorien.", totalPages, len(categories)),
|
|
PageCount: totalPages,
|
|
}, nil
|
|
}
|
|
|
|
// sortCategoriesWithFallbackLast sortiert eine Kategorienliste alphabetisch,
|
|
// verschiebt aber eine bestimmte Fallback-Kategorie (z.B. "Allgemein") ans Ende.
|
|
func sortCategoriesWithFallbackLast(cats []string, fallback string) {
|
|
// sort.Slice sortiert in-place mit einer benutzerdefinierten Vergleichsfunktion.
|
|
// "less(i, j) = true" bedeutet: Element i soll vor Element j stehen.
|
|
sort.Slice(cats, func(i, j int) bool {
|
|
if cats[i] == fallback {
|
|
return false // fallback kommt immer nach allem anderen
|
|
}
|
|
if cats[j] == fallback {
|
|
return true // fallback kommt immer nach allem anderen
|
|
}
|
|
return cats[i] < cats[j] // sonst alphabetisch
|
|
})
|
|
}
|
|
|
|
// sortEntriesByTitle sortiert CachedEntries alphabetisch nach dem Seitentitel.
|
|
func sortEntriesByTitle(entries []CachedEntry) {
|
|
sort.Slice(entries, func(i, j int) bool {
|
|
return entries[i].Page.Title < entries[j].Page.Title
|
|
})
|
|
}
|
|
|
|
// filterOutFirst gibt alle Tags einer Seite zurück außer dem ersten.
|
|
// Der erste Tag wird als Kategorie genutzt und muss nicht doppelt angezeigt werden.
|
|
func filterOutFirst(tags []string) []string {
|
|
if len(tags) <= 1 {
|
|
return nil
|
|
}
|
|
return tags[1:]
|
|
}
|