package mcpserver import ( "context" "fmt" "sync" "testing" "github.com/modelcontextprotocol/go-sdk/mcp" "munin/internal/auth" "munin/internal/store" "munin/internal/web" "munin/internal/workspace" ) // verbinde öffnet eine MCP-Sitzung, über die man mehrere Aufrufe schicken kann. // // Anders als ruf meldet es Fehler zurück, statt t.Fatalf aufzurufen — t.Fatalf // darf nur aus der Goroutine des Tests selbst kommen, nicht aus den // nebenläufigen Agenten unten. func verbinde(h *Handler, ws *workspace.Workspace, projekt store.Project, name string) (*mcp.ClientSession, func(), error) { sess := web.Session{ Subject: "key:" + ws.ID + ":" + name, WorkspaceID: ws.ID, Key: store.APIKey{ID: name, Name: name, ProjectID: projekt.ID, Role: string(auth.RoleWrite)}, Project: projekt, Role: auth.RoleWrite, } ctx := web.WithSessionForTest(context.Background(), sess) ctx = web.WithWorkspaceForTest(ctx, ws) clientTransport, serverTransport := mcp.NewInMemoryTransports() serverSession, err := h.serverFor(projekt).Connect(ctx, serverTransport, nil) if err != nil { return nil, nil, err } client := mcp.NewClient(&mcp.Implementation{Name: name, Version: "1"}, nil) clientSession, err := client.Connect(ctx, clientTransport, nil) if err != nil { serverSession.Close() return nil, nil, err } schliessen := func() { clientSession.Close() serverSession.Close() } return clientSession, schliessen, nil } // TestGleichzeitigesSchreibenHaeltSuchindexAktuell prüft, dass der Suchindex // nach gleichzeitigen Schreibvorgängen auf denselben Eintrag den Stand der // Datenbank kennt. // // Die Gefahr: remember schreibt erst in die Datenbank und aktualisiert danach // den Index — zwei getrennte Schritte. Überholen sich zwei Agenten zwischen // diesen Schritten, landet Fassung 3 in der Datenbank, aber Fassung 2 im Index. // recall fände den Eintrag dann bis zum Neustart über veraltete Wörter. // // Jeder Schreibvorgang bekommt deshalb ein eigenes, eindeutiges Wort. Am Ende // muss das Wort der letzten Fassung in der Datenbank auch im Index zu finden // sein. func TestGleichzeitigesSchreibenHaeltSuchindexAktuell(t *testing.T) { h, ws, projekt := aufbauMitBudget(t, 100000) ctx := context.Background() // Den Index vorher anlegen. Ohne ihn wäre Search.Update ein No-op und der // Test würde nichts prüfen (siehe Registry.Update). idx, err := ws.Search.For(ctx, projekt.ID) if err != nil { t.Fatal(err) } const runden, agenten, jeAgent = 20, 8, 10 for runde := 0; runde < runden; runde++ { slug := fmt.Sprintf("umkaempft-%02d", runde) var wg sync.WaitGroup var mu sync.Mutex var fehler []error for a := 0; a < agenten; a++ { wg.Add(1) go func(a int) { defer wg.Done() melde := func(err error) { mu.Lock() fehler = append(fehler, err) mu.Unlock() } client, schliessen, err := verbinde(h, ws, projekt, fmt.Sprintf("agent-%d", a)) if err != nil { melde(err) return } defer schliessen() for i := 0; i < jeAgent; i++ { // Nur Buchstaben, damit der Tokenizer das Wort nicht zerlegt. wort := fmt.Sprintf("marke%sx%s", buchstaben(runde*1000+a*100+i), "ende") erg, err := client.CallTool(ctx, &mcp.CallToolParams{ Name: "remember", Arguments: map[string]any{ "slug": slug, "title": "Umkämpft", "content": "Stand " + wort, }, }) if err != nil { melde(err) return } if erg.IsError { melde(fmt.Errorf("remember: %s", text(erg))) return } } }(a) } wg.Wait() if len(fehler) > 0 { t.Fatalf("Runde %d: %d Fehler, erster: %v", runde, len(fehler), fehler[0]) } e, err := ws.Store.EntryBySlug(ctx, projekt.ID, slug) if err != nil { t.Fatal(err) } if e.Version != agenten*jeAgent { t.Errorf("Runde %d: Fassung %d, erwartet %d — ein Schreibvorgang ging verloren", runde, e.Version, agenten*jeAgent) } // Das Wort der letzten Fassung steht nach "Stand " im Inhalt. letztesWort := e.Content[len("Stand "):] gefunden := false for _, r := range idx.Search(letztesWort, 5) { if r.DocID == slug { gefunden = true } } if !gefunden { t.Errorf("Runde %d: Datenbank hat Fassung %d mit %q, der Suchindex kennt das Wort nicht — "+ "er hält einen älteren Stand", runde, e.Version, letztesWort) } } } // buchstaben schreibt eine Zahl mit den Buchstaben a–j statt der Ziffern 0–9. // So entsteht für jeden Schreibvorgang ein eindeutiges Wort ohne Ziffern. func buchstaben(n int) string { s := fmt.Sprintf("%06d", n) b := []byte(s) for i := range b { b[i] = 'a' + (b[i] - '0') } return string(b) }