Lot 20 : serveurs MCP tenus par la façade (LOKI_MCP=rust)

La façade tient les serveurs MCP quand LOKI_MCP=rust (expérimental ; le
Go reste le défaut) : configuration (state/mcp), catalogue embarqué (le
même fichier que le Go, copié dans l'étape Rust de l'image), état et
routes /api/mcp*. Un client MCP en Rust (mcp/client.rs) parle JSON-RPC
sur stdio et en Streamable HTTP (réponses JSON ou SSE, session suivie,
en-têtes configurés, ping du serveur servi). Le pool suit mcp_client.go :
session gardée entre les tours, connexions parallèles et partagées,
session invalidée pendant sa connexion jamais republiée, délais npx/uvx,
appel borné avec une reconnexion, sortie plafonnée à la fenêtre.

Un seul pool, celui de la façade : le Go, qui exécute encore les outils
de l'agent, lui demande les outils MCP, leurs appels et le
pré-chauffage (/internal/facade/v1/mcp/*, facade_mcp.go).

Parité : parity_mcp_test.go tire du Go les fonctions pures, seize
mutations de configuration, et un vrai serveur du SDK MCP Go (onze
outils) : ce qu'il met sur le fil et ce que le client Go en tire (état,
outils annoncés, appels). tests/mcp.rs le rejoue contre un faux serveur
qui resert ces réponses, et vérifie stdio, reconnexion, routes, catalogue
et session invalidée de bout en bout ; TestFacadeMCP côté Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
Claude committed 2026-10-15 19:56:11 +00:00
1 parent 3fc36a3acc
commit 090f15615e
20 files changed
+3670 -2

No files matched your search

+3 -1
View File
@@ -150,7 +150,7 @@ var facadeChatRoutes = map[string]bool{
// de la discussion (facade_files.go), les notifications (facade_push.go), la
// mémoire (facade_mem.go), les presets (facade_presets.go), le pilotage du
// moteur (facade_engine.go), les interrupteurs système (facade_system.go), les
// projets (facade_projects.go).
// projets (facade_projects.go), les serveurs MCP (facade_mcp.go).
// Posé APRÈS requireWebAuth : seule une requête authentifiée ici part, avec le
// jeton interne qui en tient lieu là-bas.
func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc {
@@ -177,6 +177,8 @@ func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc {
held, unavailable = facadeSystem, "réglages système indisponibles : "
case facadeProjectsRoutes[path]:
held, unavailable = facadeProjects, "projets indisponibles : "
case facadeMCPRoutes[path]:
held, unavailable = facadeMCP, "serveurs MCP indisponibles : "
default:
return h
}
+70
View File
@@ -10,6 +10,7 @@ import (
"strings"
"sync"
"testing"
"time"
)
// fausseFacade : enregistre ce qui lui arrive ; /api/chat rend un flux.
@@ -835,3 +836,72 @@ func TestFacadeProjets(t *testing.T) {
t.Fatalf("corps illisible : %d", c)
}
}
// Les serveurs MCP (LOKI_MCP=rust, lot 20) : routes relayées, et le Go
// demande à la façade les outils MCP et leurs appels (un seul pool).
func TestFacadeMCP(t *testing.T) {
testHome(t)
var calls []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get(internalTokenHeader) != jetonTest {
w.WriteHeader(403)
return
}
b, _ := io.ReadAll(r.Body)
calls = append(calls, r.URL.Path+" "+string(b))
switch r.URL.Path {
case "/internal/facade/v1/mcp/tools":
io.WriteString(w, `{"tools":[{"type":"function","function":{"name":"mcp__a__t","description":"[MCP: a] d","parameters":{"type":"object"}}}]}`)
case "/internal/facade/v1/mcp/call":
io.WriteString(w, `{"result":"fait"}`)
case "/internal/facade/v1/mcp/prewarm":
io.WriteString(w, `{"ok":true}`)
default:
io.WriteString(w, `{"ok":true,"de":"facade"}`)
}
}))
defer srv.Close()
t.Setenv(internalTokenEnv, jetonTest)
t.Setenv(facadeAddrEnv, strings.TrimPrefix(srv.URL, "http://"))
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
t.Setenv(facadeMCPEnv, "")
w := httptest.NewRecorder()
withFacadeChat("/api/mcp", local)(w, httptest.NewRequest("GET", "/api/mcp", nil))
if w.Body.String() != "go" || len(calls) != 0 {
t.Fatalf("sans LOKI_MCP : %q", w.Body.String())
}
t.Setenv(facadeMCPEnv, "rust")
for p := range facadeMCPRoutes {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}")))
if !strings.Contains(w.Body.String(), "facade") {
t.Fatalf("%s non relayé : %q", p, w.Body.String())
}
}
tools := mcpTools()
if len(tools) != 1 || tools[0].Function.Name != "mcp__a__t" || mcpPromptLine(tools) == "" {
t.Fatalf("outils : %+v", tools)
}
if got := mcpCall("mcp__a__t", map[string]any{"x": 1}); got != "fait" {
t.Fatalf("appel : %q", got)
}
if got := mcpCall("mcp__a__t", nil); got != "fait" || !strings.HasSuffix(calls[len(calls)-1], `{"args":{},"name":"mcp__a__t"}`) {
t.Fatalf("appel sans arguments : %q (%v)", got, calls)
}
MCPPrewarm()
for i := 0; i < 50 && !strings.HasPrefix(calls[len(calls)-1], "/internal/facade/v1/mcp/prewarm"); i++ {
time.Sleep(20 * time.Millisecond)
}
if !strings.HasPrefix(calls[len(calls)-1], "/internal/facade/v1/mcp/prewarm") {
t.Fatalf("pré-chauffage non demandé : %v", calls)
}
// Façade injoignable : une erreur lisible, pas de panique.
t.Setenv(facadeAddrEnv, "127.0.0.1:1")
if got := mcpCall("mcp__a__t", nil); !strings.HasPrefix(got, "[erreur MCP] façade injoignable") {
t.Fatalf("façade injoignable : %q", got)
}
if tools := mcpTools(); tools != nil {
t.Fatalf("outils sans façade : %v", tools)
}
}
+105
View File
@@ -0,0 +1,105 @@
package loki
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"time"
)
// facade_mcp.go — serveurs MCP tenus par la façade Rust (LOKI_MCP=rust, lot 20
// de la migration ; expérimental, le Go reste le défaut) : configuration,
// catalogue, état et pool de sessions (rust/loki-rs/src/mcp).
//
// Un seul pool : celui de la façade. Le Go, qui exécute encore les outils de
// l'agent, lui demande les outils MCP à annoncer au modèle et leurs appels
// (/internal/facade/v1/mcp/*), quelle que soit la conversation qui tourne ;
// son propre pool reste vide.
const facadeMCPEnv = "LOKI_MCP"
// facadeMCP : la façade tient-elle les serveurs MCP ?
func facadeMCP() bool {
return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeMCPEnv)), "rust") &&
facadeAddr() != "" && internalToken() != ""
}
// facadeMCPRoutes : les routes que la façade sert alors (mcp/mod.rs).
var facadeMCPRoutes = map[string]bool{
"/api/mcp": true,
"/api/mcp/catalog": true,
"/api/mcp/save": true,
"/api/mcp/delete": true,
"/api/mcp/toggle": true,
"/api/mcp/tool": true,
"/api/mcp/test": true,
}
// facadeMCPJSON appelle l'API interne MCP de la façade et décode sa réponse.
// Délai large : un appel d'outil MCP peut durer (mcpCallTimeout), une
// première connexion npx/uvx aussi (mcpConnectTimeoutCold).
func facadeMCPJSON(path string, body any, out any) error {
b, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, "http://"+facadeAddr()+path, bytes.NewReader(b))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set(internalTokenHeader, internalToken())
resp, err := (&http.Client{Timeout: mcpConnectTimeoutCold + mcpCallTimeout}).Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("façade : %s", resp.Status)
}
return json.NewDecoder(resp.Body).Decode(out)
}
// facadeMCPTools : les outils MCP à annoncer, tenus par la façade.
func facadeMCPTools() []Tool {
var out struct {
Tools []Tool `json:"tools"`
}
if err := facadeMCPJSON("/internal/facade/v1/mcp/tools", map[string]any{}, &out); err != nil {
fmt.Fprintf(os.Stderr, "[mcp] outils demandés à la façade : %v\n", err)
return nil
}
return out.Tools
}
// facadeMCPCall : un outil MCP exécuté par la façade.
func facadeMCPCall(name string, args map[string]any) string {
if args == nil {
args = map[string]any{}
}
var out struct {
Result string `json:"result"`
}
if err := facadeMCPJSON("/internal/facade/v1/mcp/call", map[string]any{"name": name, "args": args}, &out); err != nil {
return "[erreur MCP] façade injoignable : " + err.Error()
}
return out.Result
}
// facadeMCPPrewarm : la façade ouvre ses sessions en tâche de fond.
func facadeMCPPrewarm() {
go func() {
var out map[string]any
// La façade démarre le Go puis attend qu'il réponde : elle peut ne pas
// écouter encore au tout premier instant.
for i := 0; i < 10; i++ {
if facadeMCPJSON("/internal/facade/v1/mcp/prewarm", map[string]any{}, &out) == nil {
return
}
time.Sleep(time.Second)
}
}()
}
+10
View File
@@ -256,6 +256,10 @@ func mcpEnsureAll(servers map[string]MCPServerConfig) {
// qui donnait l'impression d'une IA très lente à démarrer. Sans mode agent les
// outils MCP ne sont de toute façon pas exposés : inutile de lancer les process.
func MCPPrewarm() {
if facadeMCP() { // le pool est celui de la façade (facade_mcp.go)
facadeMCPPrewarm()
return
}
if !agentEnabled() {
return
}
@@ -282,6 +286,9 @@ func mcpExposedName(server, tool string) string {
// mcpTools connecte les serveurs activés et renvoie leurs outils, prêts à être
// annoncés au modèle. Réservé au mode agent (appelé par EnabledTools).
func mcpTools() []Tool {
if facadeMCP() { // le pool est celui de la façade (facade_mcp.go)
return facadeMCPTools()
}
servers, err := LoadMCPConfig()
if err != nil || len(servers) == 0 {
return nil
@@ -368,6 +375,9 @@ func isMCPTool(name string) bool {
// mcpCall exécute un outil MCP à partir de son nom exposé et renvoie une chaîne
// résultat (texte aplati), cappée. Reconnecte une fois si la session est morte.
func mcpCall(name string, args map[string]any) string {
if facadeMCP() { // le pool est celui de la façade (facade_mcp.go)
return facadeMCPCall(name, args)
}
mcpMgr.mu.Lock()
ref, ok := mcpMgr.registry[name]
mcpMgr.mu.Unlock()
+338
View File
@@ -0,0 +1,338 @@
//go:build linux
package loki
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
)
// parity_mcp_test.go — journal de référence des serveurs MCP (lot 20 de la
// migration Rust) : fonctions pures du client (noms exposés, schémas,
// libellés, ligne du préambule, délais, aplatissement des résultats), lecture
// et mutations de la configuration, puis un vrai serveur du SDK MCP Go servi
// en Streamable HTTP : ce qu'il annonce et renvoie (tel que sur le fil), et ce
// que le client Go en tire (état, outils annoncés au modèle, appels). Rangé
// dans rust/loki-rs/tests/parity_mcp/mcp.json ; tests/mcp.rs le rejoue contre
// un faux serveur qui sert les mêmes réponses.
//
// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteMCP
const parityMCPFile = "../../rust/loki-rs/tests/parity_mcp/mcp.json"
type parityMCPMutation struct {
Op string `json:"op"`
Name string `json:"name"`
Tool string `json:"tool,omitempty"`
On bool `json:"on,omitempty"`
Cfg string `json:"cfg,omitempty"` // MCPServerConfig en JSON
Error string `json:"error"`
State string `json:"state"` // state/mcp après l'opération
}
func TestPariteMCP(t *testing.T) {
testHome(t)
t.Cleanup(mcpCloseAll)
t.Setenv(facadeMCPEnv, "")
fx := map[string]any{}
// --- fonctions pures ---
san := map[string]string{}
for _, s := range []string{"alpha", "mon serveur!", "__x__", "a.b/c", "é-t_é", "", "...", "A-B_c9"} {
san[s] = mcpSanitize(s)
}
fx["sanitize"] = san
var schemas []any
for _, raw := range []string{`null`, `{"type":"object","properties":{"a":{"type":"string"}}}`, `true`, `[1]`, `{}`, `"x"`} {
var v any
_ = json.Unmarshal([]byte(raw), &v)
schemas = append(schemas, map[string]any{"in": raw, "out": mcpNormalizeSchema(v)})
}
fx["schemas"] = schemas
var labels []any
for _, raw := range []string{`{}`, `{"path":"/a","query":"q"}`, `{"query":"","url":"http://x"}`, `{"name":"n","command":"c"}`,
`{"x":1,"y":"<b>"}`, `{"long":"` + strings.Repeat("é", 200) + `"}`, `{"path":3,"file":"f"}`,
`{"file":"f","path":"p"}`, `{"moyen":"` + strings.Repeat("a", 150) + `"}`} {
var m map[string]any
_ = json.Unmarshal([]byte(raw), &m)
labels = append(labels, map[string]any{"in": raw, "out": mcpArgLabel(m)})
}
fx["labels"] = labels
var servers []any
for _, c := range [][2]string{
{"mcp__alpha__x", "[MCP: alpha] desc"}, {"mcp__a_b__x", "[MCP: a]b] desc"}, {"mcp__a_b_c__x", "[MCP: a] b] c] d"},
{"mcp__x__y", "pas de préfixe"}, {"autre", "[MCP: autre] d"}, {"mcp__b__x", "[MCP: a] d"},
} {
s, ok := mcpToolServer(Tool{Function: ToolFunction{Name: c[0], Description: c[1]}})
servers = append(servers, map[string]any{"name": c[0], "desc": c[1], "server": s, "ok": ok})
}
fx["tool_server"] = servers
var lines []any
for _, set := range [][][2]string{
{},
{{"bash", "shell"}},
{{"mcp__b__x", "[MCP: b] d"}, {"mcp__a__y", "[MCP: a] d"}, {"mcp__a__z", "[MCP: a] e"}},
{{"mcp__a_b__x", "[MCP: a]b] d"}},
} {
var tools []Tool
for _, p := range set {
tools = append(tools, Tool{Function: ToolFunction{Name: p[0], Description: p[1]}})
}
lines = append(lines, map[string]any{"tools": set, "out": mcpPromptLine(tools)})
}
fx["prompt_line"] = lines
timeouts := map[string]any{}
for _, cmd := range []string{"npx", "/usr/local/bin/NPX", "uvx.exe", "npx.cmd", "node", "", "uvx-x", "C:/bin/uvx.EXE"} {
cfg := MCPServerConfig{Command: cmd}
timeouts[cmd] = map[string]any{"secs": int(mcpTimeoutFor(cfg).Seconds()),
"err": mcpFriendlyErr(context.DeadlineExceeded, cfg).Error()}
}
fx["timeouts"] = timeouts
fx["friendly_other"] = mcpFriendlyErr(errors.New("autre"), MCPServerConfig{Command: "npx"}).Error()
var flat []any
for _, raw := range []string{
`{"content":[{"type":"text","text":" un "},{"type":"text","text":"deux\n"}]}`,
`{"content":[{"type":"image","data":"cG5n","mimeType":"image/png"},{"type":"audio","data":"d2F2","mimeType":"audio/wav"}]}`,
`{"content":[{"type":"resource_link","uri":"file:///a.txt","name":"a.txt","mimeType":"text/plain","size":12,"title":"A <b>"}]}`,
`{"content":[{"type":"resource","resource":{"uri":"file:///b","mimeType":"text/plain","text":"corps"}}]}`,
`{"content":[],"structuredContent":{"b":[1,2],"a":"<x> & y"}}`,
`{"content":[{"type":"text","text":" "}],"structuredContent":{"k":1}}`,
`{"content":[{"type":"text","text":"t"}],"structuredContent":{"k":1}}`,
`{"content":[]}`,
`{"content":[{"type":"text","text":"boum"}],"isError":true}`,
} {
var res mcpsdk.CallToolResult
if err := json.Unmarshal([]byte(raw), &res); err != nil {
t.Fatalf("%s : %v", raw, err)
}
flat = append(flat, map[string]any{"in": raw, "out": flattenMCPContent(&res)})
}
fx["flatten"] = flat
// --- configuration ---
var decoded []any
for _, raw := range []string{
`{"a":{"command":"npx","args":["-y","x"],"env":{"K":"v"}}}`,
`{"b":{"url":"http://h/mcp","headers":{"Authorization":"Bearer t"},"enabled":false,"disabledTools":["t1"]}}`,
`{"c":{"command":"x","enabled":null}}`,
`null`, `{}`,
} {
var m map[string]MCPServerConfig
_ = json.Unmarshal([]byte(raw), &m)
out, _ := json.Marshal(m)
tr := map[string]string{}
for n, c := range m {
tr[n] = c.Transport() + "|" + errString(c.Validate())
}
decoded = append(decoded, map[string]any{"in": raw, "out": string(out), "transports": tr})
}
fx["decode"] = decoded
var muts []parityMCPMutation
cfgOf := func(raw string) MCPServerConfig {
var c MCPServerConfig
_ = json.Unmarshal([]byte(raw), &c)
return c
}
for _, m := range []parityMCPMutation{
{Op: "set", Name: " fs ", Cfg: `{"command":"npx","args":["-y","@x/fs","<w>"],"env":{"A":"&"}}`},
{Op: "set", Name: "", Cfg: `{"command":"x"}`},
{Op: "set", Name: "a__b", Cfg: `{"command":"x"}`},
{Op: "set", Name: "both", Cfg: `{"command":"x","url":"http://u"}`},
{Op: "set", Name: "none", Cfg: `{"enabled":true}`},
{Op: "set", Name: "web", Cfg: `{"url":"http://u/mcp","headers":{"X":"1"},"enabled":false}`},
{Op: "enable", Name: "web", On: true},
{Op: "enable", Name: "nope", On: true},
{Op: "tool", Name: "fs", Tool: "write", On: false},
{Op: "tool", Name: "fs", Tool: "read", On: false},
{Op: "tool", Name: "fs", Tool: "write", On: true},
{Op: "tool", Name: "fs", Tool: "read", On: false},
{Op: "tool", Name: "nope", Tool: "x", On: false},
{Op: "delete", Name: "web"},
{Op: "delete", Name: "web"},
{Op: "delete", Name: " fs"},
} {
var err error
switch m.Op {
case "set":
err = SetMCPServer(m.Name, cfgOf(m.Cfg))
case "enable":
err = SetMCPServerEnabled(m.Name, m.On)
case "tool":
err = SetMCPToolEnabled(m.Name, m.Tool, m.On)
case "delete":
err = DeleteMCPServer(m.Name)
}
m.Error = errString(err)
m.State = getStr(bkState, "mcp")
muts = append(muts, m)
}
fx["mutations"] = muts
// --- un vrai serveur MCP (SDK Go), en Streamable HTTP ---
srvMCP := mcpsdk.NewServer(&mcpsdk.Implementation{Name: "parite", Version: "1"}, nil)
schema := map[string]any{"type": "object", "properties": map[string]any{"text": map[string]any{"type": "string"}}}
text := func(s string) []mcpsdk.Content { return []mcpsdk.Content{&mcpsdk.TextContent{Text: s}} }
tools := []struct {
tool *mcpsdk.Tool
res func(args map[string]any) *mcpsdk.CallToolResult
}{
{&mcpsdk.Tool{Name: "echo", Description: "Répète le texte <tel quel>", InputSchema: schema},
func(a map[string]any) *mcpsdk.CallToolResult {
s, _ := a["text"].(string)
return &mcpsdk.CallToolResult{Content: text(s)}
}},
{&mcpsdk.Tool{Name: "multi", Description: "Deux blocs", InputSchema: map[string]any{"type": "object"}},
func(map[string]any) *mcpsdk.CallToolResult {
return &mcpsdk.CallToolResult{Content: []mcpsdk.Content{&mcpsdk.TextContent{Text: "un"}, &mcpsdk.TextContent{Text: "deux"}}}
}},
{&mcpsdk.Tool{Name: "media", Description: "Image et son", InputSchema: map[string]any{"type": "object"}},
func(map[string]any) *mcpsdk.CallToolResult {
return &mcpsdk.CallToolResult{Content: []mcpsdk.Content{&mcpsdk.ImageContent{Data: []byte("png"), MIMEType: "image/png"},
&mcpsdk.AudioContent{Data: []byte("wav"), MIMEType: "audio/wav"}}}
}},
{&mcpsdk.Tool{Name: "link", Description: "Un lien", InputSchema: map[string]any{"type": "object"}},
func(map[string]any) *mcpsdk.CallToolResult {
sz := int64(12)
return &mcpsdk.CallToolResult{Content: []mcpsdk.Content{&mcpsdk.ResourceLink{URI: "file:///a.txt", Name: "a.txt", Title: "A", MIMEType: "text/plain", Size: &sz}}}
}},
{&mcpsdk.Tool{Name: "struct", Description: "Structuré", InputSchema: map[string]any{"type": "object"}},
func(map[string]any) *mcpsdk.CallToolResult {
return &mcpsdk.CallToolResult{Content: []mcpsdk.Content{}, StructuredContent: map[string]any{"b": []any{1, 2}, "a": "<x> & y"}}
}},
{&mcpsdk.Tool{Name: "fail", Description: "Échoue", InputSchema: map[string]any{"type": "object"}},
func(map[string]any) *mcpsdk.CallToolResult {
return &mcpsdk.CallToolResult{Content: text("boum"), IsError: true}
}},
{&mcpsdk.Tool{Name: "empty", Description: "Rien", InputSchema: map[string]any{"type": "object"}},
func(map[string]any) *mcpsdk.CallToolResult {
return &mcpsdk.CallToolResult{Content: []mcpsdk.Content{}}
}},
{&mcpsdk.Tool{Name: "big", Description: "Long", InputSchema: map[string]any{"type": "object"}},
func(map[string]any) *mcpsdk.CallToolResult {
return &mcpsdk.CallToolResult{Content: text(strings.Repeat("é", 40000))}
}},
{&mcpsdk.Tool{Name: "titled", Title: "Titre seul", InputSchema: map[string]any{"type": "object"}},
func(map[string]any) *mcpsdk.CallToolResult { return &mcpsdk.CallToolResult{Content: text("ok")} }},
{&mcpsdk.Tool{Name: "secret", Description: "Masqué", InputSchema: map[string]any{"type": "object"}},
func(map[string]any) *mcpsdk.CallToolResult { return &mcpsdk.CallToolResult{Content: text("chut")} }},
{&mcpsdk.Tool{Name: "dot.tool", Description: "Nom pointé", InputSchema: map[string]any{"type": "object", "properties": map[string]any{}}},
func(map[string]any) *mcpsdk.CallToolResult { return &mcpsdk.CallToolResult{Content: text("point")} }},
}
for _, tl := range tools {
res := tl.res
srvMCP.AddTool(tl.tool, func(_ context.Context, req *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) {
var args map[string]any
_ = json.Unmarshal(req.Params.Arguments, &args)
return res(args), nil
})
}
ts := httptest.NewServer(mcpsdk.NewStreamableHTTPHandler(func(*http.Request) *mcpsdk.Server { return srvMCP }, nil))
// Les sessions du pool et du serveur tiennent des connexions ouvertes :
// fermées avant le serveur, sans quoi Close attend indéfiniment.
defer func() {
mcpCloseAll()
for ss := range srvMCP.Sessions() {
_ = ss.Close()
}
ts.CloseClientConnections()
ts.Close()
}()
// Ce que le serveur met sur le fil (le faux serveur Rust le resservira).
cl := mcpsdk.NewClient(&mcpsdk.Implementation{Name: "t", Version: "1"}, nil)
sess, err := cl.Connect(context.Background(), &mcpsdk.StreamableClientTransport{Endpoint: ts.URL}, nil)
if err != nil {
t.Fatal(err)
}
lt, err := sess.ListTools(context.Background(), nil)
if err != nil {
t.Fatal(err)
}
wire := map[string]any{}
b, _ := json.Marshal(lt)
var ltv any
_ = json.Unmarshal(b, &ltv)
wire["tools/list"] = ltv
calls := map[string]any{}
callArgs := map[string]map[string]any{"echo": {"text": "salut <toi>"}}
for _, tl := range tools {
res, err := sess.CallTool(context.Background(), &mcpsdk.CallToolParams{Name: tl.tool.Name, Arguments: callArgs[tl.tool.Name]})
if err != nil {
t.Fatal(err)
}
b, _ := json.Marshal(res)
var v any
_ = json.Unmarshal(b, &v)
calls[tl.tool.Name] = v
}
wire["tools/call"] = calls
_ = sess.Close()
fx["wire"] = wire
// Ce que le client de Loki en tire.
_ = putStr(bkState, "mcp", "")
if err := SetMCPServer("alpha", MCPServerConfig{URL: ts.URL, Enabled: true}); err != nil {
t.Fatal(err)
}
_ = SetMCPToolEnabled("alpha", "secret", false)
_ = SetMCPServer("beta", MCPServerConfig{Command: "loki-mcp-absent-pour-test", Enabled: true})
_ = SetMCPServer("gamma", MCPServerConfig{URL: ts.URL, Enabled: false})
raw := getStr(bkState, "mcp")
raw = strings.Replace(raw, `}}`, `},"delta":{"enabled":true}}`, 1)
_ = putStr(bkState, "mcp", raw)
fx["store"] = strings.ReplaceAll(getStr(bkState, "mcp"), ts.URL, "{{URL}}")
st, err := MCPStatus()
if err != nil {
t.Fatal(err)
}
fx["status"] = st
fx["tools"] = mcpTools()
results := map[string]string{}
for _, n := range []string{"echo", "multi", "media", "link", "struct", "fail", "empty", "big", "titled", "secret", "dot_tool", "nope"} {
name := mcpExposedName("alpha", n)
results[name] = mcpCall(name, callArgs[n])
}
results["mcp__zeta__x"] = mcpCall("mcp__zeta__x", nil)
fx["calls"] = results
fx["prompt_line_live"] = mcpPromptLine(mcpTools())
got, err := parityJSON(fx)
if err != nil {
t.Fatal(err)
}
got = []byte(strings.ReplaceAll(string(got), ts.URL, "{{URL}}"))
if os.Getenv("LOKI_PARITY_UPDATE") == "1" {
if err := os.MkdirAll(filepath.Dir(parityMCPFile), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(parityMCPFile, got, 0o644); err != nil {
t.Fatal(err)
}
return
}
want, err := os.ReadFile(parityMCPFile)
if err != nil {
t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err)
}
if string(got) != string(want) {
t.Errorf("le client MCP ne produit plus le journal rangé dans %s.\n"+
"Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteMCP,\n"+
"puis cargo test --test mcp dit si le port Rust suit.\n--- obtenu ---\n%s", parityMCPFile, got)
}
}
func errString(err error) string {
if err == nil {
return ""
}
return err.Error()
}