mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-22 14:38:12 +02:00
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:
20 files changed
+3670
-2
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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, <v)
|
||||
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()
|
||||
}
|
||||
Reference in new issue
Block a user