From 090f15615eb6cad7aa2b7afe116291e3a3c389e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 19:56:11 +0000 Subject: [PATCH] =?UTF-8?q?Lot=2020=20:=20serveurs=20MCP=20tenus=20par=20l?= =?UTF-8?q?a=20fa=C3=A7ade=20(LOKI=5FMCP=3Drust)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv --- Dockerfile | 2 + README.md | 1 + ...6-10-15-rust-migration-lot20-mcp-design.md | 93 +++ internal/loki/facade_chat.go | 4 +- internal/loki/facade_chat_test.go | 70 ++ internal/loki/facade_mcp.go | 105 +++ internal/loki/mcp_client.go | 10 + internal/loki/parity_mcp_test.go | 338 ++++++++ rust/loki-rs/src/api.rs | 3 + rust/loki-rs/src/lib.rs | 1 + rust/loki-rs/src/main.rs | 8 +- rust/loki-rs/src/mcp/client.rs | 440 ++++++++++ rust/loki-rs/src/mcp/config.rs | 483 +++++++++++ rust/loki-rs/src/mcp/mod.rs | 684 ++++++++++++++++ rust/loki-rs/src/proxy.rs | 15 + rust/loki-rs/tests/common/fakego.rs | 2 + rust/loki-rs/tests/lot2.rs | 1 + rust/loki-rs/tests/mcp.rs | 651 +++++++++++++++ rust/loki-rs/tests/parity_mcp/mcp.json | 760 ++++++++++++++++++ rust/loki-rs/tests/proxy.rs | 1 + 20 files changed, 3670 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/specs/2026-10-15-rust-migration-lot20-mcp-design.md create mode 100644 internal/loki/facade_mcp.go create mode 100644 internal/loki/parity_mcp_test.go create mode 100644 rust/loki-rs/src/mcp/client.rs create mode 100644 rust/loki-rs/src/mcp/config.rs create mode 100644 rust/loki-rs/src/mcp/mod.rs create mode 100644 rust/loki-rs/tests/mcp.rs create mode 100644 rust/loki-rs/tests/parity_mcp/mcp.json diff --git a/Dockerfile b/Dockerfile index a319813..1d5fc34 100644 --- a/Dockerfile +++ b/Dockerfile @@ -53,6 +53,8 @@ WORKDIR /src # L'UI est embarquée par build.rs depuis la même source que le go:embed : les # deux binaires d'une image servent toujours la même interface. COPY internal/loki/ui/ internal/loki/ui/ +# Catalogue MCP embarqué par loki-rs (lot 20), le même que le Go. +COPY internal/loki/mcp_catalog.json internal/loki/mcp_catalog.json COPY rust/ rust/ RUN cd rust && cargo build --release --locked \ && mkdir -p /out && cp target/release/loki-rs /out/loki-rs diff --git a/README.md b/README.md index 88d8153..ae8eb86 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,7 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur : | `LOKI_MEM` | `rust` : la mémoire (éditeur des pages, index, déplacement entre projets, trackers, mode mémoire, interrupteurs du mode agent) est tenue par la façade Rust ; le chiffrement reste au Go (expérimental, migration en cours) | Go | | `LOKI_SERVE` | `rust` : la ligne de commande de llama-server est composée par la façade Rust (`loki-rs engine serve`) au lieu de `loki serve` ; Strata et l'optimiseur restent au Go (expérimental, migration en cours) | Go | | `LOKI_PRESETS` | `rust` : les presets (liste, éditeur et aperçus, suppression, placement auto, presets externes, bascule) sont tenus par la façade Rust ; l'application au moteur reste au Go (expérimental, migration en cours) | Go | +| `LOKI_MCP` | `rust` : les serveurs MCP — configuration, catalogue, état, pool de sessions stdio et HTTP — sont tenus par la façade Rust ; le Go lui demande les outils MCP de l'agent et leurs appels (expérimental, migration en cours) | Go | | `LOKI_PROJECTS` | `rust` : le hub des projets (liste et comptes, création, renommage, description, suppression, bascule) est tenu par la façade Rust ; les discussions restent à qui tient la conversation (expérimental, migration en cours) | Go | | `LOKI_SYSTEM` | `rust` : les interrupteurs système — accès internet et Crawl4AI, pilotage du navigateur, clé de complétion, écoute réseau du moteur — sont tenus par la façade Rust ; le navigateur piloté reste au Go (expérimental, migration en cours) | Go | | `LOKI_ENGINE` | `rust` : le pilotage du moteur (démarrage, arrêt, redémarrage, déchargement et rechargement de la VRAM) est tenu par la façade Rust, en conteneur avec son superviseur ; le verrou de l'optimiseur reste au Go (expérimental, migration en cours) | Go | diff --git a/docs/superpowers/specs/2026-10-15-rust-migration-lot20-mcp-design.md b/docs/superpowers/specs/2026-10-15-rust-migration-lot20-mcp-design.md new file mode 100644 index 0000000..d164ee0 --- /dev/null +++ b/docs/superpowers/specs/2026-10-15-rust-migration-lot20-mcp-design.md @@ -0,0 +1,93 @@ +# Lot 20 — serveurs MCP tenus par la façade + +## Contexte + +Le Go tenait les serveurs MCP (Model Context Protocol) : +- `mcp_config.go` : la configuration, dans `state/mcp` ; +- `mcp_catalog.go` : le catalogue embarqué ; +- `mcp_client.go` : le pool de sessions, avec le SDK MCP Go ; +- les routes `/api/mcp*` de `web_api.go`. + +Le pool sert deux usages : l'état affiché dans l'interface, et les outils +`mcp____` annoncés au modèle et exécutés pour l'agent. + +Lot 20 : la façade tient tout cela quand `LOKI_MCP=rust` (`mcp/`, +expérimental). Sans la variable, rien ne change. + +| Route | Rôle | +|---|---| +| `GET /api/mcp` | état de chaque serveur, connexion tentée pour les activés (en parallèle) | +| `GET /api/mcp/catalog` | catalogue embarqué (le même fichier que le Go), `{workspace}` résolu, entrées déjà configurées marquées, lanceur (npx, uvx) présent ou non | +| `POST /api/mcp/save` | ajoute ou remplace (actif par défaut) ; nom contrôlé, un seul transport | +| `POST /api/mcp/delete`, `/api/mcp/toggle`, `/api/mcp/tool` | retire, active ou éteint, masque ou démasque un outil | +| `POST /api/mcp/test` | reconnexion forcée | + +## Client MCP en Rust + +`mcp/client.rs` parle JSON-RPC 2.0 sur deux transports, comme le SDK Go : +- stdio : un processus local, un message par ligne ; +- Streamable HTTP : un POST par message, réponse en JSON ou en flux SSE, + session suivie par `Mcp-Session-Id`, en-têtes configurés ajoutés. + +La session s'ouvre par `initialize` puis `notifications/initialized`. Elle +sert `tools/list` (première page, comme le Go) et `tools/call`. Un `ping` du +serveur reçoit sa réponse. + +Le pool (`mcp/mod.rs`) suit `mcp_client.go` : +- une session gardée d'un tour à l'autre ; +- une entrée publiée avant la connexion, pour que deux appelants partagent + la même tentative ; +- une session invalidée pendant sa connexion n'est jamais republiée ; +- délai de 3 min pour npx et uvx (premier téléchargement), 20 s sinon ; +- un appel borné à 120 s, une reconnexion si la session est morte ; +- la sortie plafonnée à la fenêtre de contexte (12 000 au moins), une erreur + d'outil signalée, une sortie vide rendue « [ok] (aucune sortie) ». + +## Le Go + +Un seul pool, celui de la façade. Le Go, qui exécute encore les outils de +l'agent, lui demande les outils à annoncer, leurs appels et le +pré-chauffage (`/internal/facade/v1/mcp/{tools,call,prewarm}`, +`facade_mcp.go`), quelle que soit la conversation qui tourne. Son propre +pool reste vide. + +L'image copie `internal/loki/mcp_catalog.json` dans l'étape de compilation +Rust : un seul catalogue pour les deux binaires. + +## Écarts assumés + +- Les erreurs de transport (connexion refusée, flux coupé) ont les messages + du client Rust. Une erreur JSON-RPC et un binaire introuvable ont ceux du + Go. +- Un contenu « autre » (lien de ressource, ressource intégrée) est rendu en + JSON dans l'ordre des champs du SDK. Les annotations éventuelles sont dans + l'ordre alphabétique. + +## Parité + +`internal/loki/parity_mcp_test.go` tire du Go : +- les fonctions pures : noms exposés, schémas, libellés d'appel, serveur + d'origine d'un outil, ligne du préambule, délais et leurs messages, + aplatissement de neuf résultats ; +- la lecture de la configuration et seize mutations avec leurs erreurs et la + base après chacune ; +- ce qu'un vrai serveur du SDK MCP Go met sur le fil en Streamable HTTP : + onze outils, et le résultat de chacun ; +- ce que le client Go en tire : état de quatre serveurs (joignable, + binaire introuvable, désactivé, mal configuré), outils annoncés (un + masqué), appels. + +`tests/mcp.rs` rejoue les fonctions et la configuration. Un faux serveur +resert ce que le serveur du SDK a répondu (appels en SSE) : l'état, les +outils et les appels tirés par la façade doivent être ceux du Go, sur une +seule session. Sont aussi vérifiés de bout en bout : +- le transport stdio : environnement, ping du serveur, serveur mort en plein + appel puis reconnexion ; +- les routes de configuration, en-têtes compris ; +- le catalogue ; +- une session invalidée pendant sa connexion, sans processus laissé + derrière. + +Une mutation de chaque règle fait échouer les tests. Côté Go, +`TestFacadeMCP` vérifie le relais, les outils, les appels, le +pré-chauffage et une façade injoignable. diff --git a/internal/loki/facade_chat.go b/internal/loki/facade_chat.go index fc108e3..9279121 100644 --- a/internal/loki/facade_chat.go +++ b/internal/loki/facade_chat.go @@ -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 } diff --git a/internal/loki/facade_chat_test.go b/internal/loki/facade_chat_test.go index 1c17e99..847ef57 100644 --- a/internal/loki/facade_chat_test.go +++ b/internal/loki/facade_chat_test.go @@ -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) + } +} diff --git a/internal/loki/facade_mcp.go b/internal/loki/facade_mcp.go new file mode 100644 index 0000000..b4fa329 --- /dev/null +++ b/internal/loki/facade_mcp.go @@ -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) + } + }() +} diff --git a/internal/loki/mcp_client.go b/internal/loki/mcp_client.go index 14a532b..ee2bc72 100644 --- a/internal/loki/mcp_client.go +++ b/internal/loki/mcp_client.go @@ -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() diff --git a/internal/loki/parity_mcp_test.go b/internal/loki/parity_mcp_test.go new file mode 100644 index 0000000..837a7ab --- /dev/null +++ b/internal/loki/parity_mcp_test.go @@ -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":""}`, `{"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 "}]}`, + `{"content":[{"type":"resource","resource":{"uri":"file:///b","mimeType":"text/plain","text":"corps"}}]}`, + `{"content":[],"structuredContent":{"b":[1,2],"a":" & 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",""],"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 ", 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": " & 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 "}} + 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() +} diff --git a/rust/loki-rs/src/api.rs b/rust/loki-rs/src/api.rs index 71e7c0e..a0b13f7 100644 --- a/rust/loki-rs/src/api.rs +++ b/rust/loki-rs/src/api.rs @@ -48,6 +48,8 @@ pub struct Ctx { pub system: Option>, /// Hub des projets tenu par la façade (`LOKI_PROJECTS=rust`). pub projects: bool, + /// Serveurs MCP tenus par la façade (`LOKI_MCP=rust`). + pub mcp: Option>, /// Jeton de l'API interne : une requête qui le porte vient du Go. pub internal_token: String, } @@ -67,6 +69,7 @@ impl Ctx { || self.engine_ctl.is_some() || self.system.is_some() || self.projects + || self.mcp.is_some() } /// La requête vient du Go (jeton interne valide) : déjà authentifiée par diff --git a/rust/loki-rs/src/lib.rs b/rust/loki-rs/src/lib.rs index b558f16..eb95de7 100644 --- a/rust/loki-rs/src/lib.rs +++ b/rust/loki-rs/src/lib.rs @@ -25,6 +25,7 @@ pub mod engstatus; pub mod gguf; pub mod gojson; pub mod hf; +pub mod mcp; pub mod memory; pub mod models; pub mod oai; diff --git a/rust/loki-rs/src/main.rs b/rust/loki-rs/src/main.rs index 78e1b8c..a150f4c 100644 --- a/rust/loki-rs/src/main.rs +++ b/rust/loki-rs/src/main.rs @@ -156,6 +156,9 @@ fn serve() -> ExitCode { let system_rust = held_by_facade("LOKI_SYSTEM"); // LOKI_PROJECTS=rust : le hub des projets. let projects_rust = held_by_facade("LOKI_PROJECTS"); + // LOKI_MCP=rust : les serveurs MCP (le Go demande alors à la façade + // les outils MCP et leurs appels). + let mcp_rust = held_by_facade("LOKI_MCP"); let engine_rust = held_by_facade("LOKI_ENGINE") && match loki_rs::enginectl::usable( std::env::var("LOKI_CONTAINER").ok().as_deref(), @@ -185,6 +188,7 @@ fn serve() -> ExitCode { ("LOKI_ENGINE", engine_rust), ("LOKI_SYSTEM", system_rust), ("LOKI_PROJECTS", projects_rust), + ("LOKI_MCP", mcp_rust), ]; for (var, on) in held { if on { @@ -284,11 +288,13 @@ fn serve() -> ExitCode { settings: settings_rust, files, push: push_rust.then(|| std::sync::Arc::new(loki_rs::push::Push::new())), - mem: mem_rust.then(|| std::sync::Arc::new(loki_rs::memory::Memory::new(mem_home))), + mem: mem_rust + .then(|| std::sync::Arc::new(loki_rs::memory::Memory::new(mem_home.clone()))), presets: presets_rust, engine_ctl, system: system_rust.then(|| std::sync::Arc::new(loki_rs::system::System::new())), projects: projects_rust, + mcp: mcp_rust.then(|| std::sync::Arc::new(loki_rs::mcp::Mcp::new(mem_home))), }; let res = proxy::serve(listener, cfg, shutdown_signal()).await; eprintln!("[loki-rs] arrêt demandé — arrêt du serveur Go"); diff --git a/rust/loki-rs/src/mcp/client.rs b/rust/loki-rs/src/mcp/client.rs new file mode 100644 index 0000000..c009e32 --- /dev/null +++ b/rust/loki-rs/src/mcp/client.rs @@ -0,0 +1,440 @@ +//! Client MCP (Model Context Protocol) : JSON-RPC 2.0 sur deux transports, +//! comme le SDK Go qu'utilisait `mcp_client.go` : +//! +//! - stdio : un processus local, un message JSON par ligne sur stdin/stdout ; +//! - Streamable HTTP : un POST par message, réponse en JSON ou en flux SSE, +//! session suivie par l'en-tête `Mcp-Session-Id`. +//! +//! La session s'ouvre par `initialize` puis `notifications/initialized` ; le +//! client ne sert que `tools/list` (première page, comme le Go) et +//! `tools/call`. Une requête du serveur (`ping`) reçoit une réponse vide, les +//! autres « méthode inconnue ». + +use std::collections::{BTreeMap, HashMap}; +use std::sync::atomic::{AtomicBool, AtomicI64, Ordering}; +use std::sync::{Arc, Mutex}; + +use serde_json::{Map, Value, json}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{Child, ChildStdin}; +use tokio::sync::oneshot; + +use super::config::ServerConfig; + +/// Version du protocole demandée (celle du SDK Go 1.6). +pub const PROTOCOL_VERSION: &str = "2025-11-25"; + +type Pending = Arc>>>; + +struct Stdio { + stdin: Arc>, + pending: Pending, + child: tokio::sync::Mutex>, + closed: Arc, +} + +struct Http { + client: reqwest::Client, + url: String, + headers: BTreeMap, + session_id: Mutex>, + protocol: Mutex>, +} + +enum Transport { + Stdio(Stdio), + Http(Http), +} + +/// Une session MCP ouverte. +pub struct Session { + transport: Transport, + next_id: AtomicI64, +} + +/// Le message d'erreur d'`exec` du Go pour un binaire introuvable. +fn spawn_error(cmd: &str, e: &std::io::Error) -> String { + if e.kind() == std::io::ErrorKind::NotFound { + if cmd.contains('/') { + return format!("fork/exec {cmd}: no such file or directory"); + } + return format!("exec: \"{cmd}\": executable file not found in $PATH"); + } + e.to_string() +} + +/// Message d'une erreur JSON-RPC (`{code, message}`). +fn rpc_error(method: &str, err: &Value) -> String { + let msg = err + .get("message") + .and_then(Value::as_str) + .unwrap_or("erreur JSON-RPC"); + format!("calling \"{method}\": {msg}") +} + +impl Session { + /// Ouvre la session : processus ou point d'accès HTTP, puis la poignée de + /// main. `version` : celle annoncée dans `clientInfo`. + pub async fn connect(cfg: &ServerConfig, version: &str) -> Result { + let transport = match cfg.transport() { + "stdio" => Transport::Stdio(spawn(cfg)?), + "http" => { + crate::chat::engine::install_crypto(); + Transport::Http(Http { + client: reqwest::Client::builder() + .build() + .map_err(|e| e.to_string())?, + url: cfg.url.trim().to_string(), + headers: cfg.headers.clone(), + session_id: Mutex::new(None), + protocol: Mutex::new(None), + }) + } + _ => return Err("serveur MCP mal configuré (ni command ni url)".into()), + }; + let s = Session { + transport, + next_id: AtomicI64::new(1), + }; + let init = s + .request( + "initialize", + json!({ + "protocolVersion": PROTOCOL_VERSION, + "capabilities": {}, + "clientInfo": { "name": "loki", "version": version }, + }), + ) + .await; + let init = match init { + Ok(v) => v, + Err(e) => { + s.close().await; + return Err(e); + } + }; + if let Transport::Http(h) = &s.transport { + let v = init + .get("protocolVersion") + .and_then(Value::as_str) + .unwrap_or(PROTOCOL_VERSION) + .to_string(); + *h.protocol.lock().unwrap_or_else(|e| e.into_inner()) = Some(v); + } + if let Err(e) = s.notify("notifications/initialized", json!({})).await { + s.close().await; + return Err(e); + } + Ok(s) + } + + /// `tools/list` (première page, comme le client Go). + pub async fn list_tools(&self) -> Result, String> { + let r = self.request("tools/list", json!({})).await?; + Ok(r.get("tools") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default()) + } + + /// `tools/call` : le résultat brut (`content`, `structuredContent`, + /// `isError`). + pub async fn call_tool(&self, name: &str, args: &Map) -> Result { + self.request( + "tools/call", + json!({ "name": name, "arguments": Value::Object(args.clone()) }), + ) + .await + } + + /// La session est-elle tombée (processus mort) ? + pub fn is_closed(&self) -> bool { + match &self.transport { + Transport::Stdio(s) => s.closed.load(Ordering::SeqCst), + Transport::Http(_) => false, + } + } + + /// Ferme la session : processus tué, ou session HTTP terminée. + pub async fn close(&self) { + match &self.transport { + Transport::Stdio(s) => { + s.closed.store(true, Ordering::SeqCst); + if let Some(mut c) = s.child.lock().await.take() { + let _ = c.kill().await; + } + } + Transport::Http(h) => { + let sid = h + .session_id + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(); + if let Some(sid) = sid { + let mut rq = h.client.delete(&h.url).header("Mcp-Session-Id", sid); + for (k, v) in &h.headers { + rq = rq.header(k.as_str(), v.as_str()); + } + let _ = + tokio::time::timeout(std::time::Duration::from_secs(5), rq.send()).await; + } + } + } + } + + async fn request(&self, method: &str, params: Value) -> Result { + let id = self.next_id.fetch_add(1, Ordering::SeqCst); + let msg = json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }); + let resp = match &self.transport { + Transport::Stdio(s) => s.request(id, method, &msg).await?, + Transport::Http(h) => h.request(id, method, &msg).await?, + }; + if let Some(err) = resp.get("error").filter(|e| !e.is_null()) { + return Err(rpc_error(method, err)); + } + Ok(resp.get("result").cloned().unwrap_or(Value::Null)) + } + + async fn notify(&self, method: &str, params: Value) -> Result<(), String> { + let msg = json!({ "jsonrpc": "2.0", "method": method, "params": params }); + match &self.transport { + Transport::Stdio(s) => s.write(&msg).await, + Transport::Http(h) => h.post(&msg).await.map(|_| ()), + } + } +} + +impl Drop for Session { + fn drop(&mut self) { + if let Transport::Stdio(s) = &self.transport { + s.closed.store(true, Ordering::SeqCst); + if let Ok(mut g) = s.child.try_lock() + && let Some(c) = g.as_mut() + { + let _ = c.start_kill(); + } + } + } +} + +// --------------------------------------------------------------------------- +// stdio +// --------------------------------------------------------------------------- + +fn spawn(cfg: &ServerConfig) -> Result { + let mut cmd = tokio::process::Command::new(&cfg.command); + cmd.args(&cfg.args) + .envs(&cfg.env) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .kill_on_drop(true); + let mut child = cmd.spawn().map_err(|e| spawn_error(&cfg.command, &e))?; + let stdin = Arc::new(tokio::sync::Mutex::new( + child.stdin.take().ok_or("stdin indisponible")?, + )); + let stdout = child.stdout.take().ok_or("stdout indisponible")?; + let pending: Pending = Arc::new(Mutex::new(HashMap::new())); + let closed = Arc::new(AtomicBool::new(false)); + let (p2, c2, w2) = (pending.clone(), closed.clone(), stdin.clone()); + tokio::spawn(async move { + let mut lines = BufReader::new(stdout).lines(); + while let Ok(Some(line)) = lines.next_line().await { + let Ok(v) = serde_json::from_str::(&line) else { + continue; + }; + if let Some(reply) = dispatch(v, &p2) { + let mut b = serde_json::to_vec(&reply).unwrap_or_default(); + b.push(b'\n'); + let mut w = w2.lock().await; + let _ = w.write_all(&b).await; + let _ = w.flush().await; + } + } + c2.store(true, Ordering::SeqCst); + // Les appels en attente échouent : leur émetteur disparaît. + p2.lock().unwrap_or_else(|e| e.into_inner()).clear(); + }); + Ok(Stdio { + stdin, + pending, + child: tokio::sync::Mutex::new(Some(child)), + closed, + }) +} + +/// Une ligne lue : réponse à un appel en attente (rien à renvoyer), requête +/// du serveur (la réponse à lui écrire), ou notification (ignorée). +fn dispatch(v: Value, pending: &Pending) -> Option { + let has_method = v.get("method").is_some(); + match (v.get("id"), has_method) { + (Some(id), false) => { + if let Some(n) = id.as_i64() + && let Some(tx) = pending.lock().unwrap_or_else(|e| e.into_inner()).remove(&n) + { + let _ = tx.send(v); + } + None + } + (Some(id), true) => Some(if v.get("method").and_then(Value::as_str) == Some("ping") { + json!({ "jsonrpc": "2.0", "id": id, "result": {} }) + } else { + json!({ "jsonrpc": "2.0", "id": id, + "error": { "code": -32601, "message": "method not found" } }) + }), + _ => None, + } +} + +impl Stdio { + async fn write(&self, msg: &Value) -> Result<(), String> { + if self.closed.load(Ordering::SeqCst) { + return Err("connection closed".into()); + } + let mut line = serde_json::to_vec(msg).map_err(|e| e.to_string())?; + line.push(b'\n'); + let mut w = self.stdin.lock().await; + w.write_all(&line).await.map_err(|e| e.to_string())?; + w.flush().await.map_err(|e| e.to_string()) + } + + async fn request(&self, id: i64, method: &str, msg: &Value) -> Result { + let (tx, rx) = oneshot::channel(); + self.pending + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(id, tx); + if let Err(e) = self.write(msg).await { + self.pending + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(&id); + return Err(format!("calling \"{method}\": {e}")); + } + rx.await + .map_err(|_| format!("calling \"{method}\": connection closed")) + } +} + +// --------------------------------------------------------------------------- +// Streamable HTTP +// --------------------------------------------------------------------------- + +impl Http { + fn builder(&self, msg: &Value) -> reqwest::RequestBuilder { + let mut rq = self + .client + .post(&self.url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .body(serde_json::to_vec(msg).unwrap_or_default()); + if let Some(sid) = self + .session_id + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone() + { + rq = rq.header("Mcp-Session-Id", sid); + } + if let Some(p) = self + .protocol + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone() + { + rq = rq.header("MCP-Protocol-Version", p); + } + for (k, v) in &self.headers { + rq = rq.header(k.as_str(), v.as_str()); + } + rq + } + + async fn post(&self, msg: &Value) -> Result { + let resp = self.builder(msg).send().await.map_err(|e| e.to_string())?; + if let Some(sid) = resp + .headers() + .get("mcp-session-id") + .and_then(|v| v.to_str().ok()) + { + *self.session_id.lock().unwrap_or_else(|e| e.into_inner()) = Some(sid.to_string()); + } + if !resp.status().is_success() { + let st = resp.status(); + let body = resp.text().await.unwrap_or_default(); + let body = body.trim(); + return Err(if body.is_empty() { + st.to_string() + } else { + format!("{st}: {body}") + }); + } + Ok(resp) + } + + async fn request(&self, id: i64, method: &str, msg: &Value) -> Result { + let resp = self + .post(msg) + .await + .map_err(|e| format!("calling \"{method}\": {e}"))?; + let ctype = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_ascii_lowercase(); + let body = resp + .text() + .await + .map_err(|e| format!("calling \"{method}\": {e}"))?; + let mut candidates: Vec = Vec::new(); + if ctype.starts_with("text/event-stream") { + let mut data = String::new(); + for line in body.lines().chain(std::iter::once("")) { + if line.is_empty() { + if !data.is_empty() { + if let Ok(v) = serde_json::from_str::(&data) { + candidates.push(v); + } + data.clear(); + } + } else if let Some(d) = line.strip_prefix("data:") { + if !data.is_empty() { + data.push('\n'); + } + data.push_str(d.strip_prefix(' ').unwrap_or(d)); + } + } + } else if let Ok(v) = serde_json::from_str::(&body) { + match v { + Value::Array(a) => candidates.extend(a), + v => candidates.push(v), + } + } + candidates + .into_iter() + .find(|v| v.get("id").and_then(Value::as_i64) == Some(id) && v.get("method").is_none()) + .ok_or_else(|| format!("calling \"{method}\": réponse absente")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn erreurs_comme_go() { + let e = std::io::Error::from(std::io::ErrorKind::NotFound); + assert_eq!( + spawn_error("bidon", &e), + "exec: \"bidon\": executable file not found in $PATH" + ); + assert_eq!( + rpc_error( + "tools/call", + &json!({"code": -32602, "message": "outil inconnu"}) + ), + "calling \"tools/call\": outil inconnu" + ); + } +} diff --git a/rust/loki-rs/src/mcp/config.rs b/rust/loki-rs/src/mcp/config.rs new file mode 100644 index 0000000..f7eaf48 --- /dev/null +++ b/rust/loki-rs/src/mcp/config.rs @@ -0,0 +1,483 @@ +//! Configuration des serveurs MCP (`mcp_config.go`) et fonctions pures du +//! client (`mcp_client.go`) : nommage des outils, schémas, libellés, ligne du +//! préambule, délais. + +use std::collections::BTreeMap; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; + +/// `MCPServerConfig` : un seul transport renseigné, `command` (stdio) ou +/// `url` (Streamable HTTP). +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ServerConfig { + #[serde(skip_serializing_if = "String::is_empty")] + pub command: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub args: Vec, + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub env: BTreeMap, + #[serde(skip_serializing_if = "String::is_empty")] + pub url: String, + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub headers: BTreeMap, + pub enabled: bool, + #[serde(rename = "disabledTools", skip_serializing_if = "Vec::is_empty")] + pub disabled_tools: Vec, +} + +impl Default for ServerConfig { + fn default() -> Self { + ServerConfig { + command: String::new(), + args: Vec::new(), + env: BTreeMap::new(), + url: String::new(), + headers: BTreeMap::new(), + enabled: true, + disabled_tools: Vec::new(), + } + } +} + +/// Lecture tolérante : `enabled` vaut vrai quand la clé est absente (fichiers +/// à la Claude Desktop), les champs mal typés restent vides. +impl<'de> Deserialize<'de> for ServerConfig { + fn deserialize>(d: D) -> Result { + let v = Map::::deserialize(d)?; + let s = |k: &str| v.get(k).and_then(Value::as_str).unwrap_or("").to_string(); + let list = |k: &str| -> Vec { + v.get(k) + .and_then(Value::as_array) + .map(|a| { + a.iter() + .filter_map(|x| x.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() + }; + let map = |k: &str| -> BTreeMap { + v.get(k) + .and_then(Value::as_object) + .map(|o| { + o.iter() + .filter_map(|(k, x)| x.as_str().map(|s| (k.clone(), s.to_string()))) + .collect() + }) + .unwrap_or_default() + }; + Ok(ServerConfig { + command: s("command"), + args: list("args"), + env: map("env"), + url: s("url"), + headers: map("headers"), + enabled: match v.get("enabled") { + None => true, + Some(x) => x.as_bool().unwrap_or(false), + }, + disabled_tools: list("disabledTools"), + }) + } +} + +impl ServerConfig { + /// `Transport` : « stdio », « http » ou « » (mal configuré). + pub fn transport(&self) -> &'static str { + if !self.command.trim().is_empty() { + "stdio" + } else if !self.url.trim().is_empty() { + "http" + } else { + "" + } + } + + /// `Validate` : exactement un transport. + pub fn validate(&self) -> Result<(), String> { + let (cmd, url) = (!self.command.trim().is_empty(), !self.url.trim().is_empty()); + match (cmd, url) { + (true, true) => Err( + "un serveur MCP ne peut avoir à la fois 'command' (stdio) et 'url' (http)".into(), + ), + (false, false) => { + Err("un serveur MCP doit avoir soit 'command' (stdio) soit 'url' (http)".into()) + } + _ => Ok(()), + } + } + + /// `ToolDisabled`. + pub fn tool_disabled(&self, tool: &str) -> bool { + self.disabled_tools.iter().any(|t| t == tool) + } +} + +/// Les serveurs déclarés (`state/mcp`), triés par nom. +pub type Servers = BTreeMap; + +/// `loadMCPConfigLocked` : illisible ou absent → aucun serveur. +pub fn decode_servers(raw: Option<&[u8]>) -> Servers { + raw.and_then(|b| serde_json::from_slice::>(b).ok()) + .flatten() + .unwrap_or_default() +} + +/// `SetMCPServer` (sans l'invalidation du pool) : nom contrôlé, transport +/// validé. +pub fn set_server(servers: &mut Servers, name: &str, cfg: ServerConfig) -> Result<(), String> { + let name = name.trim(); + if name.is_empty() { + return Err("nom de serveur vide".into()); + } + if name.contains("__") { + return Err("le nom ne peut pas contenir '__' (réservé au namespacing des outils)".into()); + } + cfg.validate()?; + servers.insert(name.to_string(), cfg); + Ok(()) +} + +fn unknown(name: &str) -> String { + format!("serveur MCP inconnu: {name}") +} + +/// `DeleteMCPServer`. +pub fn delete_server(servers: &mut Servers, name: &str) -> Result<(), String> { + servers + .remove(name) + .map(|_| ()) + .ok_or_else(|| unknown(name)) +} + +/// `SetMCPServerEnabled`. +pub fn set_enabled(servers: &mut Servers, name: &str, on: bool) -> Result<(), String> { + let cfg = servers.get_mut(name).ok_or_else(|| unknown(name))?; + cfg.enabled = on; + Ok(()) +} + +/// `SetMCPToolEnabled` : la liste reconstruite sans l'outil, puis l'outil +/// ajouté en fin s'il est masqué. +pub fn set_tool_enabled( + servers: &mut Servers, + server: &str, + tool: &str, + on: bool, +) -> Result<(), String> { + let cfg = servers.get_mut(server).ok_or_else(|| unknown(server))?; + cfg.disabled_tools.retain(|t| t != tool); + if !on { + cfg.disabled_tools.push(tool.to_string()); + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Fonctions pures du client +// --------------------------------------------------------------------------- + +/// `mcpSanitize` : `[^a-zA-Z0-9_-]+` → `_`, puis `_` retirés aux bords. +pub fn sanitize(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut in_run = false; + for c in s.chars() { + if c.is_ascii_alphanumeric() || c == '_' || c == '-' { + out.push(c); + in_run = false; + } else if !in_run { + out.push('_'); + in_run = true; + } + } + out.trim_matches('_').to_string() +} + +/// `mcpExposedName` : `mcp____`. +pub fn exposed_name(server: &str, tool: &str) -> String { + format!("mcp__{}__{}", sanitize(server), sanitize(tool)) +} + +/// `isMCPTool`. +pub fn is_mcp_tool(name: &str) -> bool { + name.starts_with("mcp__") +} + +/// `mcpNormalizeSchema` : un objet tel quel, sinon un objet sans propriété. +pub fn normalize_schema(schema: Option<&Value>) -> Value { + match schema { + Some(Value::Object(m)) => Value::Object(m.clone()), + _ => json!({ "type": "object", "properties": {} }), + } +} + +/// `mcpArgLabel` : une clé parlante, sinon le JSON des arguments (120 runes). +pub fn arg_label(args: &Map) -> String { + if args.is_empty() { + return String::new(); + } + for k in ["path", "file", "query", "command", "url", "name"] { + if let Some(Value::String(v)) = args.get(k) + && !v.is_empty() + { + return v.clone(); + } + } + let s = crate::gojson::marshal(args); + if s.chars().count() > 120 { + return s.chars().take(120).collect::() + "…"; + } + s +} + +/// `mcpTagDescription`. +pub fn tag_description(server: &str, desc: &str) -> String { + format!("[MCP: {server}] {desc}") +} + +/// `mcpToolServer` : le serveur d'origine d'un outil annoncé, d'après le +/// préfixe de sa description (la première coupure dont la forme assainie +/// figure bien dans le nom de l'outil). +pub fn tool_server(name: &str, description: &str) -> Option { + if !name.starts_with("mcp__") { + return None; + } + let rest = description.strip_prefix("[MCP: ")?; + let mut i = 0; + loop { + let j = rest[i..].find("] ")?; + let server = &rest[..i + j]; + if name.starts_with(&format!("mcp__{}__", sanitize(server))) { + return Some(server.to_string()); + } + i += j + 1; + } +} + +/// `mcpPromptLine` : les serveurs des outils annoncés ce tour-ci, et leur +/// nombre d'outils. `tools` : (nom, description). +pub fn prompt_line(tools: &[(String, String)]) -> String { + let mut counts: BTreeMap = BTreeMap::new(); + for (n, d) in tools { + if let Some(s) = tool_server(n, d) { + *counts.entry(s).or_default() += 1; + } + } + if counts.is_empty() { + return String::new(); + } + let mut names: Vec = counts.iter().map(|(s, n)| format!("{s} ({n})")).collect(); + names.sort(); + format!( + "\n\nMCP servers connected (their tools are prefixed mcp____): {}.", + names.join(", ") + ) +} + +/// `mcpConnectTimeout`. +pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(20); +/// `mcpConnectTimeoutCold` : npx et uvx téléchargent le paquet au premier +/// lancement. +pub const CONNECT_TIMEOUT_COLD: Duration = Duration::from_secs(180); +/// `mcpCallTimeout`. +pub const CALL_TIMEOUT: Duration = Duration::from_secs(120); + +/// Nom du lanceur (`filepath.Base`), en minuscules, sans `.exe` puis sans +/// `.cmd`. +fn launcher(cfg: &ServerConfig) -> String { + let t = cfg.command.trim_end_matches('/'); + let base = if t.is_empty() { + if cfg.command.is_empty() { "." } else { "/" } + } else { + t.rsplit('/').next().unwrap_or(t) + }; + let base = base.to_lowercase(); + let b = base.strip_suffix(".exe").unwrap_or(&base); + b.strip_suffix(".cmd").unwrap_or(b).to_string() +} + +/// `mcpTimeoutFor`. +pub fn timeout_for(cfg: &ServerConfig) -> Duration { + match launcher(cfg).as_str() { + "npx" | "uvx" => CONNECT_TIMEOUT_COLD, + _ => CONNECT_TIMEOUT, + } +} + +/// `mcpFriendlyErr` pour un délai dépassé. +pub fn timeout_error(cfg: &ServerConfig) -> String { + let l = launcher(cfg); + if l == "npx" || l == "uvx" { + return format!( + "délai dépassé — {l} télécharge le paquet du serveur au premier lancement, ce qui peut être long ; clique « tester » pour réessayer (le paquet reste en cache), et vérifie que le conteneur atteint le registre (npm/PyPI)" + ); + } + "délai dépassé — le serveur n'a pas répondu au handshake en 20s".into() +} + +/// `mcpOutputCap` : environ un caractère par jeton de contexte, jamais sous +/// 12 000. +pub fn output_cap(ctx: i64) -> usize { + 12000.max(ctx.max(0) as usize) +} + +/// `ctxWindow` : `CTX` de la configuration, 32 768 à défaut. +pub fn ctx_window(cfg: &std::collections::HashMap) -> i64 { + cfg.get("CTX") + .and_then(|v| v.parse::().ok()) + .filter(|n| *n > 0) + .unwrap_or(32768) +} + +/// Le texte d'un résultat d'outil, tronqué au plafond (en runes). +pub fn cap_output(out: String, lim: usize) -> String { + let n = out.chars().count(); + if n <= lim { + return out; + } + let kept: String = out.chars().take(lim).collect(); + format!("{kept}\n…[tronqué : {n} caractères au total, {lim} gardés]") +} + +/// `flattenMCPContent` : blocs texte concaténés, images et sons résumés, les +/// autres rendus en JSON ; le contenu structuré en JSON indenté s'il n'y a pas +/// de texte. +pub fn flatten_content(result: &Value) -> String { + let mut parts: Vec = Vec::new(); + for c in result + .get("content") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let kind = c.get("type").and_then(Value::as_str).unwrap_or(""); + let mime = c.get("mimeType").and_then(Value::as_str).unwrap_or(""); + match kind { + "text" => parts.push(c.get("text").and_then(Value::as_str).unwrap_or("").into()), + "image" => parts.push(format!("[image {mime}]")), + "audio" => parts.push(format!("[audio {mime}]")), + _ => parts.push(wire_content_json(c)), + } + } + let text = parts.join("\n").trim().to_string(); + if text.is_empty() + && let Some(sc) = result.get("structuredContent").filter(|v| !v.is_null()) + { + return String::from_utf8(crate::chatx::marshal_indent(sc)).unwrap_or_default(); + } + text +} + +/// `json.Marshal` d'un contenu « autre » par le SDK Go (`wireContent`) : +/// champs dans l'ordre de sa struct, vides omis. +fn wire_content_json(c: &Value) -> String { + let order = [ + "type", + "text", + "mimeType", + "data", + "resource", + "uri", + "name", + "title", + "description", + "size", + "_meta", + "annotations", + "icons", + "id", + "input", + "toolUseId", + "content", + "structuredContent", + "isError", + ]; + let mut out = String::from("{"); + let mut first = true; + for k in order { + let Some(v) = c.get(k) else { continue }; + let empty = match v { + Value::Null => true, + Value::String(s) => s.is_empty(), + Value::Bool(b) => !b, + Value::Array(a) => a.is_empty(), + Value::Object(o) => o.is_empty() && k != "resource", + Value::Number(_) => false, + }; + if empty && k != "type" { + continue; + } + if !first { + out.push(','); + } + first = false; + out.push_str(&crate::gojson::marshal(k)); + out.push(':'); + if k == "resource" { + out.push_str(&resource_contents_json(v)); + } else { + out.push_str(&crate::gojson::marshal(v)); + } + } + out.push('}'); + out +} + +/// `ResourceContents` du SDK Go : `uri` toujours, puis `mimeType`, `text`, +/// `blob`, `_meta` s'ils sont renseignés. +fn resource_contents_json(v: &Value) -> String { + let mut out = String::from("{"); + let mut first = true; + for (k, always) in [ + ("uri", true), + ("mimeType", false), + ("text", false), + ("blob", false), + ("_meta", false), + ] { + let x = v.get(k).cloned().unwrap_or(Value::Null); + let empty = match &x { + Value::Null => true, + Value::String(s) => s.is_empty(), + Value::Object(o) => o.is_empty(), + _ => false, + }; + if empty && !always { + continue; + } + if !first { + out.push(','); + } + first = false; + out.push_str(&crate::gojson::marshal(k)); + out.push(':'); + let x = if x.is_null() && always { json!("") } else { x }; + out.push_str(&crate::gojson::marshal(&x)); + } + out.push('}'); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn noms() { + assert_eq!(sanitize("mon serveur!"), "mon_serveur"); + assert_eq!(exposed_name("a b", "c.d"), "mcp__a_b__c_d"); + } + + #[test] + fn lanceurs() { + let c = |cmd: &str| ServerConfig { + command: cmd.into(), + ..Default::default() + }; + assert_eq!(timeout_for(&c("/usr/bin/npx")), CONNECT_TIMEOUT_COLD); + assert_eq!(timeout_for(&c("UVX.EXE")), CONNECT_TIMEOUT_COLD); + assert_eq!(timeout_for(&c("npx.cmd")), CONNECT_TIMEOUT_COLD); + assert_eq!(timeout_for(&c("node")), CONNECT_TIMEOUT); + } +} diff --git a/rust/loki-rs/src/mcp/mod.rs b/rust/loki-rs/src/mcp/mod.rs new file mode 100644 index 0000000..312a932 --- /dev/null +++ b/rust/loki-rs/src/mcp/mod.rs @@ -0,0 +1,684 @@ +//! Serveurs MCP tenus par la façade (lot 20 de la migration, +//! `LOKI_MCP=rust` ; expérimental, le Go reste le défaut) — port de +//! `mcp_client.go`, `mcp_config.go`, `mcp_catalog.go` et des routes +//! `/api/mcp*` de `web_api.go`. +//! +//! Le pool de sessions vit ici : une session reste ouverte d'un tour à +//! l'autre (un serveur stdio garde son processus), les connexions se font en +//! parallèle, deux appelants simultanés partagent la même tentative. Le Go, +//! qui exécute encore les outils de l'agent, demande à la façade la liste des +//! outils MCP et leurs appels (`/internal/facade/v1/mcp/*`) : un seul pool, +//! quelle que soit la conversation qui tourne. + +pub mod client; +pub mod config; + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, LazyLock, Mutex}; + +use futures_util::future::join_all; +use http_body_util::{BodyExt, Limited}; +use hyper::body::Incoming; +use hyper::{Request, Response, StatusCode}; +use serde_json::{Map, Value, json}; + +use crate::api::Ctx; +use crate::auth::json as send_json; +use crate::gojson::{self, Kind}; +use crate::proxy::Body; +use crate::store::{BK_STATE, Store}; +use client::Session; +use config::{ServerConfig, Servers}; + +/// Clé de la configuration (`state/mcp`). +pub const ST_MCP: &str = "mcp"; + +/// Chemins de l'API interne de la façade, pour le Go. +pub const INTERNAL_TOOLS: &str = "/internal/facade/v1/mcp/tools"; +pub const INTERNAL_CALL: &str = "/internal/facade/v1/mcp/call"; +pub const INTERNAL_PREWARM: &str = "/internal/facade/v1/mcp/prewarm"; + +/// Issue d'une tentative de connexion. +#[derive(Default)] +struct SlotState { + sess: Option>, + tools: Vec, + err: Option, +} + +/// Une entrée du pool, publiée AVANT la connexion : un second appelant attend +/// son issue au lieu de lancer un deuxième processus. +struct Slot { + cfg: ServerConfig, + state: tokio::sync::OnceCell, +} + +/// Le pool des sessions MCP et le registre nom exposé → (serveur, outil). +pub struct Mcp { + home: PathBuf, + slots: Mutex>>, + registry: Mutex>, + cfg_lock: tokio::sync::Mutex<()>, +} + +fn lock(m: &Mutex) -> std::sync::MutexGuard<'_, T> { + m.lock().unwrap_or_else(|e| e.into_inner()) +} + +impl Mcp { + pub fn new(home: PathBuf) -> Self { + Mcp { + home, + slots: Mutex::new(HashMap::new()), + registry: Mutex::new(HashMap::new()), + cfg_lock: tokio::sync::Mutex::new(()), + } + } + + /// `LoadMCPConfig`. + pub async fn load(store: &Store) -> Servers { + config::decode_servers(store.get(BK_STATE, ST_MCP).await.ok().flatten().as_deref()) + } + + async fn save(store: &Store, servers: &Servers) -> Result<(), String> { + store + .put(BK_STATE, ST_MCP, gojson::marshal(servers).into_bytes()) + .await + } + + /// Une mutation de la configuration, sérialisée, puis l'invalidation du + /// serveur touché. + async fn mutate( + &self, + store: &Store, + name: &str, + f: impl FnOnce(&mut Servers) -> Result<(), String>, + ) -> Result<(), String> { + { + let _g = self.cfg_lock.lock().await; + let mut servers = Self::load(store).await; + f(&mut servers)?; + Self::save(store, &servers).await?; + } + self.invalidate(name).await; + Ok(()) + } + + /// `mcpInvalidate` : la session du serveur fermée et oubliée, ses outils + /// retirés du registre. + pub async fn invalidate(&self, name: &str) { + let slot = lock(&self.slots).remove(name); + lock(&self.registry).retain(|_, (server, _)| server != name); + // Une session encore en cours de connexion se refermera d'elle-même en + // constatant qu'elle n'est plus dans le pool. + if let Some(s) = slot + && let Some(st) = s.state.get() + && let Some(sess) = &st.sess + { + sess.close().await; + } + } + + /// `mcpCloseAll`. + pub async fn close_all(&self) { + let slots: Vec<_> = lock(&self.slots).drain().map(|(_, s)| s).collect(); + lock(&self.registry).clear(); + for s in slots { + if let Some(st) = s.state.get() + && let Some(sess) = &st.sess + { + sess.close().await; + } + } + } + + /// `ensure` : la session du serveur, connectée ou en erreur. + async fn ensure(&self, name: &str, cfg: &ServerConfig, version: &str) -> Arc { + let slot = lock(&self.slots) + .entry(name.to_string()) + .or_insert_with(|| { + Arc::new(Slot { + cfg: cfg.clone(), + state: tokio::sync::OnceCell::new(), + }) + }) + .clone(); + slot.state + .get_or_init(|| async { + let cfg = &slot.cfg; + let mut st = SlotState::default(); + let work = async { + let sess = Session::connect(cfg, version).await?; + let tools = sess.list_tools().await; + Ok::<_, String>((sess, tools)) + }; + match tokio::time::timeout(config::timeout_for(cfg), work).await { + Err(_) => st.err = Some(config::timeout_error(cfg)), + Ok(Err(e)) => st.err = Some(e), + Ok(Ok((sess, tools))) => { + match tools { + Ok(t) => st.tools = t, + Err(e) => st.err = Some(e), + } + st.sess = Some(Arc::new(sess)); + } + } + // Invalidée pendant la connexion : jamais republiée, refermée. + let current = lock(&self.slots) + .get(name) + .is_some_and(|s| Arc::ptr_eq(s, &slot)); + if current { + let mut reg = lock(&self.registry); + reg.retain(|_, (server, _)| server != name); + for t in &st.tools { + let tn = t.get("name").and_then(Value::as_str).unwrap_or(""); + reg.insert( + config::exposed_name(name, tn), + (name.to_string(), tn.to_string()), + ); + } + } else if let Some(sess) = st.sess.take() { + sess.close().await; + } + st + }) + .await; + slot + } + + /// `mcpEnsureAll` : les serveurs activés connectés en parallèle. + async fn ensure_all(&self, servers: &Servers, version: &str) { + join_all( + servers + .iter() + .filter(|(_, c)| c.enabled && !c.transport().is_empty()) + .map(|(n, c)| self.ensure(n, c, version)), + ) + .await; + } + + /// `mcpTools` : les outils des serveurs activés, prêts à être annoncés au + /// modèle (forme `Tool` du Go). + pub async fn tools(&self, store: &Store, version: &str) -> Vec { + let servers = Self::load(store).await; + if servers.is_empty() { + return Vec::new(); + } + self.ensure_all(&servers, version).await; + let mut out = Vec::new(); + for (name, cfg) in &servers { + if !cfg.enabled || cfg.transport().is_empty() { + continue; + } + let slot = self.ensure(name, cfg, version).await; + let Some(st) = slot.state.get() else { continue }; + if st.sess.is_none() { + continue; + } + for t in &st.tools { + let tn = t.get("name").and_then(Value::as_str).unwrap_or(""); + if cfg.tool_disabled(tn) { + continue; + } + let mut desc = t.get("description").and_then(Value::as_str).unwrap_or(""); + if desc.is_empty() { + desc = t.get("title").and_then(Value::as_str).unwrap_or(""); + } + out.push(json!({ + "type": "function", + "function": { + "name": config::exposed_name(name, tn), + "description": config::tag_description(name, desc), + "parameters": config::normalize_schema(t.get("inputSchema")), + }, + })); + } + } + out + } + + /// `mcpCall` : un outil par son nom exposé ; une reconnexion si la + /// session est morte. + pub async fn call( + &self, + store: &Store, + name: &str, + args: &Map, + version: &str, + ) -> String { + let Some((server, tool)) = lock(&self.registry).get(name).cloned() else { + return format!("[erreur] outil MCP inconnu ou serveur non connecté: {name}"); + }; + let cap = config::output_cap(config::ctx_window(&store.config().await)); + let mut err = match self.call_once(&server, &tool, args, cap).await { + Ok(r) => return r, + Err(e) => e, + }; + let servers = Self::load(store).await; + if let Some(cfg) = servers.get(&server).filter(|c| c.enabled) { + self.invalidate(&server).await; + self.ensure(&server, cfg, version).await; + match self.call_once(&server, &tool, args, cap).await { + Ok(r) => return r, + Err(e) => err = e, + } + } + format!("[erreur MCP] {err}") + } + + async fn call_once( + &self, + server: &str, + tool: &str, + args: &Map, + cap: usize, + ) -> Result { + let slot = lock(&self.slots).get(server).cloned(); + let sess = slot + .as_ref() + .and_then(|s| s.state.get()) + .and_then(|st| st.sess.clone()) + .filter(|s| !s.is_closed()) + .ok_or_else(|| format!("serveur {server} non connecté"))?; + let res = tokio::time::timeout(config::CALL_TIMEOUT, sess.call_tool(tool, args)) + .await + .map_err(|_| "context deadline exceeded".to_string())??; + let out = config::cap_output(config::flatten_content(&res), cap); + if res.get("isError").and_then(Value::as_bool) == Some(true) { + return Ok(format!("[l'outil a renvoyé une erreur]\n{out}")); + } + if out.trim().is_empty() { + return Ok("[ok] (aucune sortie)".into()); + } + Ok(out) + } + + /// `MCPStatus` : l'état de chaque serveur, connexion tentée pour les + /// activés. + pub async fn status(&self, store: &Store, version: &str) -> Vec { + let servers = Self::load(store).await; + self.ensure_all(&servers, version).await; + let mut out = Vec::new(); + for (name, cfg) in &servers { + let mut st = json!({ + "name": name, + "transport": cfg.transport(), + "enabled": cfg.enabled, + "connected": false, + "tools": [], + "disabled": cfg.disabled_tools, + }); + for (k, v) in [ + ("command", json!(cfg.command)), + ("args", json!(cfg.args)), + ("env", json!(cfg.env)), + ("url", json!(cfg.url)), + ("headers", json!(cfg.headers)), + ] { + let empty = match &v { + Value::String(s) => s.is_empty(), + Value::Array(a) => a.is_empty(), + Value::Object(o) => o.is_empty(), + _ => false, + }; + if !empty { + st[k] = v; + } + } + if cfg.enabled && !cfg.transport().is_empty() { + let slot = self.ensure(name, cfg, version).await; + if let Some(s) = slot.state.get() { + if s.sess.is_some() { + st["connected"] = json!(true); + st["tools"] = json!( + s.tools + .iter() + .map(|t| t.get("name").and_then(Value::as_str).unwrap_or("")) + .collect::>() + ); + } + if let Some(e) = &s.err { + st["error"] = json!(e); + } + } + } + out.push(st); + } + out + } + + /// `MCPPrewarm` : les sessions ouvertes en tâche de fond, en mode agent + /// seulement (sans lui, aucun outil MCP n'est annoncé). + pub fn prewarm(self: &Arc, store: Store, version: String) { + let me = self.clone(); + tokio::spawn(async move { + if store.get_str(BK_STATE, "agent").await != "1" { + return; + } + let servers = Self::load(&store).await; + if !servers.is_empty() { + me.ensure_all(&servers, &version).await; + } + }); + } + + /// `MCPCatalog` : le catalogue embarqué, chemins résolus, entrées déjà + /// configurées marquées. + pub async fn catalog(&self, store: &Store) -> Result, String> { + let entries = catalog_entries()?; + let configured = Self::load(store).await; + let ws = workspace_dir(&self.home); + let expand = |s: &str| s.replace("{workspace}", &ws); + let mut out = Vec::new(); + for e in entries { + let mut e = e.clone(); + let obj = e.as_object_mut().ok_or("entrée du catalogue illisible")?; + if let Some(Value::Array(a)) = obj.get_mut("args") { + for x in a.iter_mut() { + if let Value::String(s) = x { + *s = expand(s); + } + } + } + if let Some(Value::Object(m)) = obj.get_mut("env") { + for x in m.values_mut() { + if let Value::String(s) = x { + *s = expand(s); + } + } + } + for k in ["homepage", "runtime"] { + if obj.get(k).and_then(Value::as_str) == Some("") { + obj.remove(k); + } + } + for k in ["args", "secrets"] { + if obj + .get(k) + .and_then(Value::as_array) + .is_some_and(|a| a.is_empty()) + { + obj.remove(k); + } + } + if obj + .get("env") + .and_then(Value::as_object) + .is_some_and(|m| m.is_empty()) + { + obj.remove("env"); + } + let id = obj.get("id").and_then(Value::as_str).unwrap_or(""); + let runtime = obj.get("runtime").and_then(Value::as_str).unwrap_or(""); + let (installed, runtime_ok) = (configured.contains_key(id), runtime_available(runtime)); + obj.insert("installed".into(), json!(installed)); + obj.insert("runtimeOk".into(), json!(runtime_ok)); + out.push(e); + } + Ok(out) + } +} + +/// `workspaceDir` : le dossier de travail de l'agent. +fn workspace_dir(home: &Path) -> String { + crate::paths::go_join(&[&home.to_string_lossy(), "workspace"]) +} + +/// `mcpRuntimeAvailable`. +fn runtime_available(runtime: &str) -> bool { + match runtime { + "node" => crate::paths::look_path("npx").is_some(), + "python" => crate::paths::look_path("uvx").is_some(), + _ => true, + } +} + +/// Le catalogue embarqué (celui du Go, `mcp_catalog.json`). +static CATALOG: LazyLock, String>> = LazyLock::new(|| { + let raw = include_str!("../../../../internal/loki/mcp_catalog.json"); + let v: Value = + serde_json::from_str(raw).map_err(|e| format!("catalogue MCP illisible : {e}"))?; + Ok(v.get("servers") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() + .into_iter() + .map(|mut e| { + // Les champs du type Go, dans son ordre (le reste est ignoré). + let mut o = Map::new(); + for (k, def) in [ + ("id", json!("")), + ("name", json!("")), + ("category", json!("")), + ("description", json!("")), + ("homepage", json!("")), + ("runtime", json!("")), + ("command", json!("")), + ("args", json!([])), + ("env", json!({})), + ("secrets", json!([])), + ] { + o.insert(k.into(), e.get_mut(k).map(Value::take).unwrap_or(def)); + } + Value::Object(o) + }) + .collect()) +}); + +fn catalog_entries() -> Result<&'static Vec, String> { + CATALOG.as_ref().map_err(Clone::clone) +} + +// --------------------------------------------------------------------------- +// Routes +// --------------------------------------------------------------------------- + +/// Les routes servies par ce module. +pub fn handles(path: &str) -> bool { + matches!( + path, + "/api/mcp" + | "/api/mcp/catalog" + | "/api/mcp/save" + | "/api/mcp/delete" + | "/api/mcp/toggle" + | "/api/mcp/tool" + | "/api/mcp/test" + ) +} + +fn fail(code: StatusCode, e: impl Into) -> Response { + send_json(code, &json!({ "ok": false, "error": e.into() })) +} + +const SAVE_REQ_TYPE: &str = "mcpSaveReq"; +const NAME_REQ_TYPE: &str = r#"struct { Name string "json:\"name\"" }"#; +const TOGGLE_REQ_TYPE: &str = r#"struct { Name string "json:\"name\""; On bool "json:\"on\"" }"#; +const TOOL_REQ_TYPE: &str = + r#"struct { Name string "json:\"name\""; Tool string "json:\"tool\""; On bool "json:\"on\"" }"#; + +async fn read_body(body: Incoming) -> Vec { + Limited::new(body, 1 << 20) + .collect() + .await + .map(|b| b.to_bytes().to_vec()) + .unwrap_or_default() +} + +/// La version du Go (annoncée aux serveurs dans `clientInfo`). +async fn go_version(ctx: &Ctx) -> String { + ctx.internal + .auth() + .await + .map(|i| i.version) + .unwrap_or_default() +} + +/// `from_go` : remise par le Go (tunnel), déjà authentifiée par lui. +pub async fn serve( + ctx: &Ctx, + mcp: &Arc, + req: Request, + from_go: bool, +) -> Response { + let (parts, body) = req.into_parts(); + let version = if from_go { + go_version(ctx).await + } else { + match ctx.guard(&parts.headers).await { + Ok(v) => v, + Err(refus) => return *refus, + } + }; + let store = &ctx.store; + let servers_ok = + |list: Vec| send_json(StatusCode::OK, &json!({ "ok": true, "servers": list })); + match parts.uri.path() { + "/api/mcp" => servers_ok(mcp.status(store, &version).await), + "/api/mcp/catalog" => match mcp.catalog(store).await { + Ok(c) => send_json(StatusCode::OK, &json!({ "ok": true, "catalog": c })), + Err(e) => fail(StatusCode::INTERNAL_SERVER_ERROR, e), + }, + "/api/mcp/save" => { + let raw = read_body(body).await; + let d = gojson::decode_struct( + &raw, + SAVE_REQ_TYPE, + &[ + ("name", Kind::Str), + ("command", Kind::Str), + ("args", Kind::StrList), + ("env", Kind::StrMap), + ("url", Kind::Str), + ("headers", Kind::StrMap), + ("enabled", Kind::OptBool), + ], + ); + if let Some(e) = d.err.clone() { + return fail(StatusCode::BAD_REQUEST, e); + } + let cfg = ServerConfig { + command: d.str("command").trim().to_string(), + args: d.str_list("args").unwrap_or_default(), + env: d.str_map("env").unwrap_or_default().into_iter().collect(), + url: d.str("url").trim().to_string(), + headers: d + .str_map("headers") + .unwrap_or_default() + .into_iter() + .collect(), + enabled: d.opt_bool("enabled").unwrap_or(true), + disabled_tools: Vec::new(), + }; + let name = d.str("name"); + let res = mcp + .mutate(store, name.trim(), |s| config::set_server(s, &name, cfg)) + .await; + if let Err(e) = res { + return fail(StatusCode::BAD_REQUEST, e); + } + servers_ok(mcp.status(store, &version).await) + } + "/api/mcp/delete" => { + let raw = read_body(body).await; + let d = gojson::decode_struct(&raw, NAME_REQ_TYPE, &[("name", Kind::Str)]); + if let Some(e) = d.err.clone() { + return fail(StatusCode::BAD_REQUEST, e); + } + let name = d.str("name"); + if let Err(e) = mcp + .mutate(store, &name, |s| config::delete_server(s, &name)) + .await + { + return fail(StatusCode::BAD_REQUEST, e); + } + send_json(StatusCode::OK, &json!({ "ok": true })) + } + "/api/mcp/toggle" => { + let raw = read_body(body).await; + let d = gojson::decode_struct( + &raw, + TOGGLE_REQ_TYPE, + &[("name", Kind::Str), ("on", Kind::Bool)], + ); + if let Some(e) = d.err.clone() { + return fail(StatusCode::BAD_REQUEST, e); + } + let (name, on) = (d.str("name"), d.bool("on")); + if let Err(e) = mcp + .mutate(store, &name, |s| config::set_enabled(s, &name, on)) + .await + { + return fail(StatusCode::BAD_REQUEST, e); + } + servers_ok(mcp.status(store, &version).await) + } + "/api/mcp/tool" => { + let raw = read_body(body).await; + let d = gojson::decode_struct( + &raw, + TOOL_REQ_TYPE, + &[("name", Kind::Str), ("tool", Kind::Str), ("on", Kind::Bool)], + ); + if let Some(e) = d.err.clone() { + return fail(StatusCode::BAD_REQUEST, e); + } + let (name, tool, on) = (d.str("name"), d.str("tool"), d.bool("on")); + if let Err(e) = mcp + .mutate(store, &name, |s| { + config::set_tool_enabled(s, &name, &tool, on) + }) + .await + { + return fail(StatusCode::BAD_REQUEST, e); + } + servers_ok(mcp.status(store, &version).await) + } + "/api/mcp/test" => { + let raw = read_body(body).await; + let d = gojson::decode_struct(&raw, NAME_REQ_TYPE, &[("name", Kind::Str)]); + if let Some(e) = d.err.clone() { + return fail(StatusCode::BAD_REQUEST, e); + } + mcp.invalidate(&d.str("name")).await; + servers_ok(mcp.status(store, &version).await) + } + _ => fail(StatusCode::NOT_FOUND, "route inconnue"), + } +} + +/// L'API interne de la façade pour le Go (jeton interne vérifié par +/// l'appelant) : outils annoncés, appels, pré-chauffage. +pub async fn serve_internal(ctx: &Ctx, mcp: &Arc, req: Request) -> Response { + let path = req.uri().path().to_string(); + let raw = read_body(req.into_body()).await; + let version = go_version(ctx).await; + match path.as_str() { + INTERNAL_TOOLS => send_json( + StatusCode::OK, + &json!({ "tools": mcp.tools(&ctx.store, &version).await }), + ), + INTERNAL_CALL => { + let v: Value = serde_json::from_slice(&raw).unwrap_or_default(); + let name = v.get("name").and_then(Value::as_str).unwrap_or(""); + let args = v + .get("args") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + let out = mcp.call(&ctx.store, name, &args, &version).await; + send_json(StatusCode::OK, &json!({ "result": out })) + } + INTERNAL_PREWARM => { + mcp.prewarm(ctx.store.clone(), version); + send_json(StatusCode::OK, &json!({ "ok": true })) + } + _ => fail(StatusCode::NOT_FOUND, "route inconnue"), + } +} + +/// Les chemins de l'API interne de ce module. +pub fn is_internal(path: &str) -> bool { + matches!(path, INTERNAL_TOOLS | INTERNAL_CALL | INTERNAL_PREWARM) +} diff --git a/rust/loki-rs/src/proxy.rs b/rust/loki-rs/src/proxy.rs index ec8a8bf..b6a56db 100644 --- a/rust/loki-rs/src/proxy.rs +++ b/rust/loki-rs/src/proxy.rs @@ -87,6 +87,9 @@ pub struct Config { pub system: Option>, /// Hub des projets tenu par la façade (`LOKI_PROJECTS=rust`). pub projects: bool, + /// Serveurs MCP (pool de sessions, configuration, catalogue) tenus par la + /// façade (`LOKI_MCP=rust`) ; `None` : au Go. + pub mcp: Option>, } struct Proxy { @@ -124,6 +127,7 @@ pub async fn serve( engine_ctl: cfg.engine_ctl, system: cfg.system, projects: cfg.projects, + mcp: cfg.mcp, internal_token: cfg.internal_token.clone(), }, }); @@ -192,6 +196,12 @@ impl Proxy { d.shutdown_fast().await; return plain(StatusCode::NO_CONTENT, ""); } + if from_go + && let Some(m) = &self.ctx.mcp + && crate::mcp::is_internal(path) + { + return crate::mcp::serve_internal(&self.ctx, m, req).await; + } // L'API interne du Go n'est jamais joignable de l'extérieur. if path.starts_with("/internal/") { return plain(StatusCode::NOT_FOUND, "404 page not found"); @@ -216,6 +226,11 @@ impl Proxy { { return chatapi::serve(&self.ctx, chat, req, from_go).await; } + if let Some(m) = &self.ctx.mcp + && crate::mcp::handles(path) + { + return crate::mcp::serve(&self.ctx, m, req, from_go).await; + } if self.ctx.projects && crate::projects_api::handles(path) { return crate::projects_api::serve(&self.ctx, req, from_go).await; } diff --git a/rust/loki-rs/tests/common/fakego.rs b/rust/loki-rs/tests/common/fakego.rs index 71c89da..4466dcf 100644 --- a/rust/loki-rs/tests/common/fakego.rs +++ b/rust/loki-rs/tests/common/fakego.rs @@ -633,6 +633,7 @@ pub struct Opts { pub engine_ctl: Option>, pub system: Option>, pub projects: bool, + pub mcp: Option>, } /// Comme [`start`], avec la conversation tenue par la façade @@ -732,6 +733,7 @@ pub async fn start_with( engine_ctl: opts.engine_ctl, system: opts.system, projects: opts.projects, + mcp: opts.mcp, }; tokio::spawn(loki_rs::proxy::serve(fl, cfg, async move { let _ = rx.await; diff --git a/rust/loki-rs/tests/lot2.rs b/rust/loki-rs/tests/lot2.rs index 0a76390..e57a6f0 100644 --- a/rust/loki-rs/tests/lot2.rs +++ b/rust/loki-rs/tests/lot2.rs @@ -104,6 +104,7 @@ async fn banc(cle: Option<&str>) -> Banc { engine_ctl: None, system: None, projects: false, + mcp: None, }; tokio::spawn(loki_rs::proxy::serve(l, cfg, async move { let _ = rx.await; diff --git a/rust/loki-rs/tests/mcp.rs b/rust/loki-rs/tests/mcp.rs new file mode 100644 index 0000000..87fd3d5 --- /dev/null +++ b/rust/loki-rs/tests/mcp.rs @@ -0,0 +1,651 @@ +//! Lot 20 : serveurs MCP tenus par la façade (`LOKI_MCP=rust`). +//! +//! Le journal tiré du Go (`internal/loki/parity_mcp_test.go` → +//! `tests/parity_mcp/mcp.json`) est rejoué : fonctions pures, lecture et +//! mutations de la configuration, puis un faux serveur MCP (Streamable HTTP) +//! qui resert ce qu'un vrai serveur du SDK Go a mis sur le fil — l'état, les +//! outils annoncés au modèle et les résultats d'appel tirés par la façade +//! doivent être ceux du client Go. Le transport stdio, les routes de +//! configuration et l'API interne pour le Go sont vérifiés de bout en bout. + +mod common; + +use std::collections::BTreeMap; +use std::convert::Infallible; +use std::sync::{Arc, Mutex}; + +use bytes::Bytes; +use common::fakego; +use http_body_util::{BodyExt, Full}; +use hyper::service::service_fn; +use hyper::{Request, Response}; +use hyper_util::rt::TokioIo; +use loki_rs::mcp::{self, Mcp, config as cf}; +use serde_json::{Map, Value, json}; +use tokio::net::TcpListener; + +fn fixture() -> Value { + serde_json::from_str(include_str!("parity_mcp/mcp.json")).expect("journal") +} + +fn s(v: &Value) -> &str { + v.as_str().unwrap() +} + +#[test] +fn parite_des_fonctions_pures() { + let fx = fixture(); + for (k, v) in fx["sanitize"].as_object().unwrap() { + assert_eq!(cf::sanitize(k), s(v), "{k:?}"); + } + for c in fx["schemas"].as_array().unwrap() { + let v: Value = serde_json::from_str(s(&c["in"])).unwrap(); + assert_eq!(cf::normalize_schema(Some(&v)), c["out"], "{}", c["in"]); + } + assert_eq!( + cf::normalize_schema(None), + json!({"type":"object","properties":{}}) + ); + for c in fx["labels"].as_array().unwrap() { + let m: Map = serde_json::from_str(s(&c["in"])).unwrap(); + assert_eq!(cf::arg_label(&m), s(&c["out"]), "{}", c["in"]); + } + for c in fx["tool_server"].as_array().unwrap() { + let got = cf::tool_server(s(&c["name"]), s(&c["desc"])); + assert_eq!(got.is_some(), c["ok"].as_bool().unwrap(), "{c}"); + assert_eq!(got.unwrap_or_default(), s(&c["server"]), "{c}"); + } + for c in fx["prompt_line"].as_array().unwrap() { + let tools: Vec<(String, String)> = c["tools"] + .as_array() + .map(|a| { + a.iter() + .map(|p| (s(&p[0]).to_string(), s(&p[1]).to_string())) + .collect() + }) + .unwrap_or_default(); + assert_eq!(cf::prompt_line(&tools), s(&c["out"]), "{c}"); + } + for (cmd, v) in fx["timeouts"].as_object().unwrap() { + let cfg = cf::ServerConfig { + command: cmd.clone(), + ..Default::default() + }; + assert_eq!( + cf::timeout_for(&cfg).as_secs() as i64, + v["secs"].as_i64().unwrap(), + "{cmd}" + ); + assert_eq!(cf::timeout_error(&cfg), s(&v["err"]), "{cmd}"); + } + for c in fx["flatten"].as_array().unwrap() { + let v: Value = serde_json::from_str(s(&c["in"])).unwrap(); + assert_eq!(cf::flatten_content(&v), s(&c["out"]), "{}", c["in"]); + } +} + +#[test] +fn parite_de_la_configuration() { + let fx = fixture(); + for c in fx["decode"].as_array().unwrap() { + let m = cf::decode_servers(Some(s(&c["in"]).as_bytes())); + let out = if s(&c["in"]) == "null" && m.is_empty() { + "null".to_string() + } else { + loki_rs::gojson::marshal(&m) + }; + assert_eq!(out, s(&c["out"]), "{}", c["in"]); + for (n, want) in c["transports"].as_object().unwrap() { + let cfg = &m[n]; + let got = format!( + "{}|{}", + cfg.transport(), + cfg.validate().err().unwrap_or_default() + ); + assert_eq!(got, s(want), "{n}"); + } + } + let mut servers = cf::Servers::new(); + let mut saved = false; + for m in fx["mutations"].as_array().unwrap() { + let name = s(&m["name"]); + let res = match s(&m["op"]) { + "set" => { + let cfg: cf::ServerConfig = serde_json::from_str(s(&m["cfg"])).unwrap(); + cf::set_server(&mut servers, name, cfg) + } + "enable" => cf::set_enabled(&mut servers, name, m["on"].as_bool().unwrap_or(false)), + "tool" => cf::set_tool_enabled( + &mut servers, + name, + s(&m["tool"]), + m["on"].as_bool().unwrap_or(false), + ), + "delete" => cf::delete_server(&mut servers, name), + op => panic!("{op}"), + }; + saved |= res.is_ok(); + assert_eq!(res.err().unwrap_or_default(), s(&m["error"]), "{m}"); + let state = if saved { + loki_rs::gojson::marshal(&servers) + } else { + String::new() + }; + assert_eq!(state, s(&m["state"]), "{m}"); + } +} + +/// Un faux serveur MCP en Streamable HTTP : resert ce que le serveur du SDK +/// Go a répondu. `tools/call` en flux SSE, le reste en JSON. +async fn fake_server(wire: Value, seen: Arc>>) -> String { + let l = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = l.local_addr().unwrap(); + tokio::spawn(async move { + while let Ok((st, _)) = l.accept().await { + let (wire, seen) = (wire.clone(), seen.clone()); + tokio::spawn(async move { + let svc = service_fn(move |req: Request| { + let (wire, seen) = (wire.clone(), seen.clone()); + async move { + let sid = req + .headers() + .get("mcp-session-id") + .map(|v| v.to_str().unwrap().to_string()); + let auth = req + .headers() + .get("x-cle") + .map(|v| v.to_str().unwrap().to_string()); + let method = req.method().clone(); + let b = req.into_body().collect().await.unwrap().to_bytes(); + let mut r = Response::new(Full::new(Bytes::new())); + if method == hyper::Method::DELETE { + seen.lock().unwrap().push("DELETE".into()); + return Ok::<_, Infallible>(r); + } + let v: Value = serde_json::from_slice(&b).unwrap(); + let m = v["method"].as_str().unwrap_or("").to_string(); + seen.lock().unwrap().push(format!( + "{m} {} {}", + sid.as_deref().unwrap_or("-"), + auth.as_deref().unwrap_or("-") + )); + let result = match m.as_str() { + "initialize" => { + r.headers_mut() + .insert("Mcp-Session-Id", "sess-1".parse().unwrap()); + json!({ "protocolVersion": "2025-06-18", + "capabilities": { "tools": {} }, + "serverInfo": { "name": "faux", "version": "1" } }) + } + "tools/list" => wire["tools/list"].clone(), + "tools/call" => { + let name = v["params"]["name"].as_str().unwrap(); + let res = wire["tools/call"][name].clone(); + let body = format!( + "event: message\ndata: {}\n\n", + json!({ "jsonrpc": "2.0", "id": v["id"], "result": res }) + ); + r.headers_mut() + .insert("content-type", "text/event-stream".parse().unwrap()); + *r.body_mut() = Full::new(Bytes::from(body)); + return Ok(r); + } + _ => { + *r.status_mut() = hyper::StatusCode::ACCEPTED; + return Ok(r); + } + }; + r.headers_mut() + .insert("content-type", "application/json".parse().unwrap()); + *r.body_mut() = Full::new(Bytes::from( + json!({ "jsonrpc": "2.0", "id": v["id"], "result": result }) + .to_string(), + )); + Ok(r) + } + }); + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection(TokioIo::new(st), svc) + .await; + }); + } + }); + format!("http://{addr}") +} + +async fn internal(facade: std::net::SocketAddr, path: &str, body: Value) -> Value { + let (code, b) = fakego::send( + facade, + "POST", + path, + &[(loki_rs::auth::INTERNAL_HEADER, fakego::JETON)], + &body.to_string(), + ) + .await; + assert_eq!(code, 200, "{path}"); + serde_json::from_slice(&b).unwrap() +} + +#[tokio::test] +async fn parite_contre_le_serveur_du_sdk() { + let fx = fixture(); + let seen = Arc::new(Mutex::new(Vec::new())); + let url = fake_server(fx["wire"].clone(), seen.clone()).await; + let tmp = tempfile::tempdir().unwrap(); + let pool = Arc::new(Mcp::new(tmp.path().to_path_buf())); + let (facade, go, _stop, _) = fakego::start_with( + tmp.path().to_path_buf(), + fakego::Opts { + mcp: Some(pool.clone()), + ..Default::default() + }, + ) + .await; + go.set("state", "mcp", &s(&fx["store"]).replace("{{URL}}", &url)); + let mask = |v: &Value| -> Value { + serde_json::from_str(&v.to_string().replace(&url, "{{URL}}")).unwrap() + }; + + let (_, b) = fakego::send(facade, "GET", "/api/mcp", &[], "").await; + let v: Value = serde_json::from_slice(&b).unwrap(); + assert_eq!(v["ok"], true); + assert_eq!(mask(&v["servers"]), fx["status"]); + + let tools = internal(facade, mcp::INTERNAL_TOOLS, json!({})).await; + assert_eq!(tools["tools"], fx["tools"]); + let pairs: Vec<(String, String)> = tools["tools"] + .as_array() + .unwrap() + .iter() + .map(|t| { + ( + s(&t["function"]["name"]).to_string(), + s(&t["function"]["description"]).to_string(), + ) + }) + .collect(); + assert_eq!(cf::prompt_line(&pairs), s(&fx["prompt_line_live"])); + + for (name, want) in fx["calls"].as_object().unwrap() { + let args = if name == "mcp__alpha__echo" { + json!({ "text": "salut " }) + } else { + json!({}) + }; + let r = internal( + facade, + mcp::INTERNAL_CALL, + json!({ "name": name, "args": args }), + ) + .await; + assert_eq!(r["result"], *want, "{name}"); + } + // Une seule session, partagée par l'état, les outils et les appels. + let seen = seen.lock().unwrap().clone(); + assert_eq!( + seen.iter().filter(|l| l.starts_with("initialize")).count(), + 1, + "{seen:?}" + ); + assert!(seen.iter().any(|l| l == "tools/call sess-1 -"), "{seen:?}"); +} + +/// Un faux serveur MCP en stdio : sh, un message par ligne. +fn stdio_server(dir: &std::path::Path) -> String { + let p = dir.join("faux-mcp"); + let body = r#"#!/bin/sh +while IFS= read -r line; do + id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9]*\).*/\1/p') + case "$line" in + *'"method":"initialize"'*) + printf '{"jsonrpc":"2.0","method":"notifications/message","params":{}}\n' + printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":"2025-06-18","capabilities":{},"serverInfo":{"name":"sh","version":"1"}}}\n' "$id" ;; + *'"method":"tools/list"'*) + printf '{"jsonrpc":"2.0","id":99,"method":"ping"}\n' + printf '{"jsonrpc":"2.0","id":%s,"result":{"tools":[{"name":"date","description":"Heure","inputSchema":{"type":"object"}},{"name":"crash","inputSchema":{"type":"object"}}]}}\n' "$id" ;; + *'"name":"crash"'*) exit 3 ;; + *'"method":"tools/call"'*) + printf '{"jsonrpc":"2.0","id":%s,"result":{"content":[{"type":"text","text":"%s"}]}}\n' "$id" "$FAUX_ENV" ;; + *'"result":{}'*) echo "pong reçu" >> "$TRACE" ;; + esac +done +"#; + let ok = std::process::Command::new("sh") + .args(["-c", "printf %s \"$1\" > \"$2\" && chmod 755 \"$2\""]) + .arg("sh") + .arg(body) + .arg(&p) + .status() + .unwrap() + .success(); + assert!(ok); + p.to_string_lossy().into_owned() +} + +#[tokio::test] +async fn transport_stdio_et_reconnexion() { + let tmp = tempfile::tempdir().unwrap(); + let bin = stdio_server(tmp.path()); + let trace = tmp.path().join("trace"); + let pool = Arc::new(Mcp::new(tmp.path().to_path_buf())); + let (facade, go, _stop, _) = fakego::start_with( + tmp.path().to_path_buf(), + fakego::Opts { + mcp: Some(pool.clone()), + ..Default::default() + }, + ) + .await; + let mut env = BTreeMap::new(); + env.insert("FAUX_ENV".to_string(), "valeur d'env".to_string()); + env.insert("TRACE".to_string(), trace.to_string_lossy().into_owned()); + let mut servers = cf::Servers::new(); + servers.insert( + "sh".into(), + cf::ServerConfig { + command: bin, + env, + ..Default::default() + }, + ); + go.set("state", "mcp", &loki_rs::gojson::marshal(&servers)); + let (_, b) = fakego::send(facade, "GET", "/api/mcp", &[], "").await; + let v: Value = serde_json::from_slice(&b).unwrap(); + assert_eq!(v["servers"][0]["connected"], true, "{v}"); + assert_eq!(v["servers"][0]["tools"], json!(["date", "crash"])); + let r = internal( + facade, + mcp::INTERNAL_CALL, + json!({ "name": "mcp__sh__date", "args": {} }), + ) + .await; + assert_eq!(r["result"], "valeur d'env"); + // Le serveur meurt en plein appel : une reconnexion, puis l'erreur. + let r = internal( + facade, + mcp::INTERNAL_CALL, + json!({ "name": "mcp__sh__crash", "args": {} }), + ) + .await; + let out = s(&r["result"]); + assert!( + out.starts_with("[erreur MCP] calling \"tools/call\""), + "{out}" + ); + // Après la reconnexion, le serveur répond de nouveau. + let r = internal( + facade, + mcp::INTERNAL_CALL, + json!({ "name": "mcp__sh__date", "args": {} }), + ) + .await; + assert_eq!(r["result"], "valeur d'env"); + // Le ping du serveur a reçu sa réponse. + let t = std::fs::read_to_string(&trace).unwrap_or_default(); + assert!(t.contains("pong reçu"), "{t:?}"); + pool.close_all().await; +} + +#[tokio::test] +async fn routes_de_configuration() { + let tmp = tempfile::tempdir().unwrap(); + let seen = Arc::new(Mutex::new(Vec::new())); + let fx = fixture(); + let url = fake_server(fx["wire"].clone(), seen.clone()).await; + let pool = Arc::new(Mcp::new(tmp.path().to_path_buf())); + let (facade, go, _stop, _) = fakego::start_with( + tmp.path().to_path_buf(), + fakego::Opts { + mcp: Some(pool.clone()), + ..Default::default() + }, + ) + .await; + let post = |path: &'static str, body: String| async move { + let (c, b) = fakego::send(facade, "POST", path, &[], &body).await; + (c.as_u16(), serde_json::from_slice::(&b).unwrap()) + }; + // Ajout : actif par défaut, en-têtes envoyés, état rendu. + let (c, v) = post( + "/api/mcp/save", + json!({ "name": " web ", "url": format!(" {url} "), "headers": { "X-Cle": "k" } }) + .to_string(), + ) + .await; + assert_eq!(c, 200, "{v}"); + assert_eq!(v["servers"][0]["name"], "web"); + assert_eq!(v["servers"][0]["connected"], true); + assert!( + seen.lock().unwrap().iter().any(|l| l == "initialize - k"), + "{:?}", + seen.lock().unwrap() + ); + let stored: Value = serde_json::from_str(&go.get("state", "mcp").unwrap()).unwrap(); + assert_eq!(stored["web"]["url"], json!(url)); + // Erreurs de la configuration et du JSON. + let (c, v) = post("/api/mcp/save", r#"{"name":"a__b","command":"x"}"#.into()).await; + assert_eq!( + (c, v["error"].clone()), + ( + 400, + json!("le nom ne peut pas contenir '__' (réservé au namespacing des outils)") + ) + ); + let (c, v) = post( + "/api/mcp/save", + r#"{"name":"x","args":"pas une liste"}"#.into(), + ) + .await; + assert_eq!( + (c, v["error"].clone()), + ( + 400, + json!( + "json: cannot unmarshal string into Go struct field mcpSaveReq.args of type []string" + ) + ) + ); + // Un outil masqué : connu de l'état, absent des outils annoncés. + let (c, v) = post( + "/api/mcp/tool", + r#"{"name":"web","tool":"secret","on":false}"#.into(), + ) + .await; + assert_eq!( + (c, v["servers"][0]["disabled"].clone()), + (200, json!(["secret"])) + ); + let tools = internal(facade, mcp::INTERNAL_TOOLS, json!({})).await; + assert!( + !tools["tools"] + .as_array() + .unwrap() + .iter() + .any(|t| t["function"]["name"] == "mcp__web__secret") + ); + // Éteint : plus de connexion, plus d'outils ; la session est fermée. + let (c, v) = post("/api/mcp/toggle", r#"{"name":"web","on":false}"#.into()).await; + assert_eq!( + (c, v["servers"][0]["connected"].clone()), + (200, json!(false)) + ); + assert!(seen.lock().unwrap().iter().any(|l| l == "DELETE")); + let tools = internal(facade, mcp::INTERNAL_TOOLS, json!({})).await; + assert_eq!(tools["tools"], json!([])); + let (c, v) = post("/api/mcp/toggle", r#"{"name":"nope","on":true}"#.into()).await; + assert_eq!( + (c, v["error"].clone()), + (400, json!("serveur MCP inconnu: nope")) + ); + // Tester : reconnexion. + post("/api/mcp/toggle", r#"{"name":"web","on":true}"#.into()).await; + let before = seen + .lock() + .unwrap() + .iter() + .filter(|l| l.starts_with("initialize")) + .count(); + let (c, _) = post("/api/mcp/test", r#"{"name":"web"}"#.into()).await; + assert_eq!(c, 200); + let after = seen + .lock() + .unwrap() + .iter() + .filter(|l| l.starts_with("initialize")) + .count(); + assert_eq!(after, before + 1); + // Suppression. + let (c, v) = post("/api/mcp/delete", r#"{"name":"web"}"#.into()).await; + assert_eq!((c, v), (200, json!({"ok": true}))); + assert_eq!(go.get("state", "mcp").unwrap(), "{}"); + let (c, v) = post("/api/mcp/delete", r#"{"name":"web"}"#.into()).await; + assert_eq!( + (c, v["error"].clone()), + (400, json!("serveur MCP inconnu: web")) + ); + pool.close_all().await; +} + +#[tokio::test] +async fn catalogue_embarque() { + let tmp = tempfile::tempdir().unwrap(); + let pool = Arc::new(Mcp::new(tmp.path().to_path_buf())); + let (facade, go, _stop, _) = fakego::start_with( + tmp.path().to_path_buf(), + fakego::Opts { + mcp: Some(pool), + ..Default::default() + }, + ) + .await; + go.set("state", "mcp", r#"{"filesystem":{"command":"npx"}}"#); + let (_, b) = fakego::send(facade, "GET", "/api/mcp/catalog", &[], "").await; + let v: Value = serde_json::from_slice(&b).unwrap(); + let cat = v["catalog"].as_array().unwrap(); + assert!(cat.len() > 10); + let fs = cat.iter().find(|e| e["id"] == "filesystem").unwrap(); + assert_eq!(fs["installed"], true); + let ws = format!("{}/workspace", tmp.path().display()); + assert!(fs["args"].as_array().unwrap().contains(&json!(ws)), "{fs}"); + assert!( + fs.get("env").is_none() && fs.get("secrets").is_none(), + "{fs}" + ); + assert!(cat.iter().all(|e| e.get("runtimeOk").is_some())); + assert!(cat.iter().filter(|e| e["installed"] == true).count() == 1); +} + +#[tokio::test] +async fn sans_la_variable_mcp_reste_au_go() { + let tmp = tempfile::tempdir().unwrap(); + let (facade, _go, _stop) = fakego::start(tmp.path().to_path_buf()).await; + for p in [ + "/api/mcp", + "/api/mcp/save", + "/api/mcp/catalog", + "/api/mcp/test", + ] { + let (_, _, b) = fakego::get(facade, p, &[]).await; + assert_eq!(&b[..], format!("go:{p}").as_bytes()); + } + // L'API interne MCP de la façade n'existe pas sans le lot. + let (c, _) = fakego::send( + facade, + "POST", + mcp::INTERNAL_TOOLS, + &[(loki_rs::auth::INTERNAL_HEADER, fakego::JETON)], + "{}", + ) + .await; + assert_eq!(c, 404); +} + +/// Processus vivants dont la ligne de commande contient `needle`. +fn alive(needle: &str) -> usize { + std::fs::read_dir("/proc") + .unwrap() + .flatten() + .filter(|e| { + e.file_name() + .to_string_lossy() + .bytes() + .all(|b| b.is_ascii_digit()) + }) + .filter(|e| { + std::fs::read(e.path().join("cmdline")) + .map(|b| String::from_utf8_lossy(&b).contains(needle)) + .unwrap_or(false) + }) + .filter(|e| { + // Un zombie n'est plus un serveur vivant. + std::fs::read_to_string(e.path().join("stat")) + .map(|s| !s.contains(") Z ")) + .unwrap_or(false) + }) + .count() +} + +#[tokio::test] +async fn session_invalidee_pendant_la_connexion() { + let tmp = tempfile::tempdir().unwrap(); + let p = tmp.path().join("lent-mcp"); + let body = r#"#!/bin/sh +while IFS= read -r line; do + id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9]*\).*/\1/p') + case "$line" in + *'"method":"initialize"'*) + sleep 1 + printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":"2025-06-18","capabilities":{},"serverInfo":{"name":"lent","version":"1"}}}\n' "$id" ;; + *'"method":"tools/list"'*) + printf '{"jsonrpc":"2.0","id":%s,"result":{"tools":[{"name":"t","inputSchema":{"type":"object"}}]}}\n' "$id" ;; + esac +done +"#; + assert!( + std::process::Command::new("sh") + .args(["-c", "printf %s \"$1\" > \"$2\" && chmod 755 \"$2\""]) + .arg("sh") + .arg(body) + .arg(&p) + .status() + .unwrap() + .success() + ); + let bin = p.to_string_lossy().into_owned(); + let pool = Arc::new(Mcp::new(tmp.path().to_path_buf())); + let (facade, go, _stop, _) = fakego::start_with( + tmp.path().to_path_buf(), + fakego::Opts { + mcp: Some(pool.clone()), + ..Default::default() + }, + ) + .await; + let mut servers = cf::Servers::new(); + servers.insert( + "lent".into(), + cf::ServerConfig { + command: bin.clone(), + ..Default::default() + }, + ); + go.set("state", "mcp", &loki_rs::gojson::marshal(&servers)); + // Une première connexion part, puis le serveur est invalidé avant qu'elle + // aboutisse (« tester ») : elle ne doit jamais être republiée. + let first = tokio::spawn(async move { fakego::send(facade, "GET", "/api/mcp", &[], "").await }); + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + let (c, b) = fakego::send(facade, "POST", "/api/mcp/test", &[], r#"{"name":"lent"}"#).await; + assert_eq!(c, 200); + let v: Value = serde_json::from_slice(&b).unwrap(); + assert_eq!(v["servers"][0]["connected"], true); + let _ = first.await; + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + assert_eq!( + alive(&bin), + 1, + "un seul serveur vivant après l'invalidation" + ); + pool.close_all().await; + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + assert_eq!(alive(&bin), 0); +} diff --git a/rust/loki-rs/tests/parity_mcp/mcp.json b/rust/loki-rs/tests/parity_mcp/mcp.json new file mode 100644 index 0000000..b744a9b --- /dev/null +++ b/rust/loki-rs/tests/parity_mcp/mcp.json @@ -0,0 +1,760 @@ +{ + "calls": { + "mcp__alpha__big": "éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé\n…[tronqué : 40000 caractères au total, 32768 gardés]", + "mcp__alpha__dot_tool": "point", + "mcp__alpha__echo": "salut ", + "mcp__alpha__empty": "[ok] (aucune sortie)", + "mcp__alpha__fail": "[l'outil a renvoyé une erreur]\nboum", + "mcp__alpha__link": "{\"type\":\"resource_link\",\"mimeType\":\"text/plain\",\"uri\":\"file:///a.txt\",\"name\":\"a.txt\",\"title\":\"A\",\"size\":12}", + "mcp__alpha__media": "[image image/png]\n[audio audio/wav]", + "mcp__alpha__multi": "un\ndeux", + "mcp__alpha__nope": "[erreur] outil MCP inconnu ou serveur non connecté: mcp__alpha__nope", + "mcp__alpha__secret": "chut", + "mcp__alpha__struct": "{\n \"a\": \"\\u003cx\\u003e \\u0026 y\",\n \"b\": [\n 1,\n 2\n ]\n}", + "mcp__alpha__titled": "ok", + "mcp__zeta__x": "[erreur] outil MCP inconnu ou serveur non connecté: mcp__zeta__x" + }, + "decode": [ + { + "in": "{\"a\":{\"command\":\"npx\",\"args\":[\"-y\",\"x\"],\"env\":{\"K\":\"v\"}}}", + "out": "{\"a\":{\"command\":\"npx\",\"args\":[\"-y\",\"x\"],\"env\":{\"K\":\"v\"},\"enabled\":true}}", + "transports": { + "a": "stdio|" + } + }, + { + "in": "{\"b\":{\"url\":\"http://h/mcp\",\"headers\":{\"Authorization\":\"Bearer t\"},\"enabled\":false,\"disabledTools\":[\"t1\"]}}", + "out": "{\"b\":{\"url\":\"http://h/mcp\",\"headers\":{\"Authorization\":\"Bearer t\"},\"enabled\":false,\"disabledTools\":[\"t1\"]}}", + "transports": { + "b": "http|" + } + }, + { + "in": "{\"c\":{\"command\":\"x\",\"enabled\":null}}", + "out": "{\"c\":{\"command\":\"x\",\"enabled\":false}}", + "transports": { + "c": "stdio|" + } + }, + { + "in": "null", + "out": "null", + "transports": {} + }, + { + "in": "{}", + "out": "{}", + "transports": {} + } + ], + "flatten": [ + { + "in": "{\"content\":[{\"type\":\"text\",\"text\":\" un \"},{\"type\":\"text\",\"text\":\"deux\\n\"}]}", + "out": "un \ndeux" + }, + { + "in": "{\"content\":[{\"type\":\"image\",\"data\":\"cG5n\",\"mimeType\":\"image/png\"},{\"type\":\"audio\",\"data\":\"d2F2\",\"mimeType\":\"audio/wav\"}]}", + "out": "[image image/png]\n[audio audio/wav]" + }, + { + "in": "{\"content\":[{\"type\":\"resource_link\",\"uri\":\"file:///a.txt\",\"name\":\"a.txt\",\"mimeType\":\"text/plain\",\"size\":12,\"title\":\"A \"}]}", + "out": "{\"type\":\"resource_link\",\"mimeType\":\"text/plain\",\"uri\":\"file:///a.txt\",\"name\":\"a.txt\",\"title\":\"A \\u003cb\\u003e\",\"size\":12}" + }, + { + "in": "{\"content\":[{\"type\":\"resource\",\"resource\":{\"uri\":\"file:///b\",\"mimeType\":\"text/plain\",\"text\":\"corps\"}}]}", + "out": "{\"type\":\"resource\",\"resource\":{\"uri\":\"file:///b\",\"mimeType\":\"text/plain\",\"text\":\"corps\"}}" + }, + { + "in": "{\"content\":[],\"structuredContent\":{\"b\":[1,2],\"a\":\" & y\"}}", + "out": "{\n \"a\": \"\\u003cx\\u003e \\u0026 y\",\n \"b\": [\n 1,\n 2\n ]\n}" + }, + { + "in": "{\"content\":[{\"type\":\"text\",\"text\":\" \"}],\"structuredContent\":{\"k\":1}}", + "out": "{\n \"k\": 1\n}" + }, + { + "in": "{\"content\":[{\"type\":\"text\",\"text\":\"t\"}],\"structuredContent\":{\"k\":1}}", + "out": "t" + }, + { + "in": "{\"content\":[]}", + "out": "" + }, + { + "in": "{\"content\":[{\"type\":\"text\",\"text\":\"boum\"}],\"isError\":true}", + "out": "boum" + } + ], + "friendly_other": "autre", + "labels": [ + { + "in": "{}", + "out": "" + }, + { + "in": "{\"path\":\"/a\",\"query\":\"q\"}", + "out": "/a" + }, + { + "in": "{\"query\":\"\",\"url\":\"http://x\"}", + "out": "http://x" + }, + { + "in": "{\"name\":\"n\",\"command\":\"c\"}", + "out": "c" + }, + { + "in": "{\"x\":1,\"y\":\"\"}", + "out": "{\"x\":1,\"y\":\"\\u003cb\\u003e\"}" + }, + { + "in": "{\"long\":\"éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé\"}", + "out": "{\"long\":\"ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé…" + }, + { + "in": "{\"path\":3,\"file\":\"f\"}", + "out": "f" + }, + { + "in": "{\"file\":\"f\",\"path\":\"p\"}", + "out": "p" + }, + { + "in": "{\"moyen\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}", + "out": "{\"moyen\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa…" + } + ], + "mutations": [ + { + "op": "set", + "name": " fs ", + "cfg": "{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\"],\"env\":{\"A\":\"&\"}}", + "error": "", + "state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true}}" + }, + { + "op": "set", + "name": "", + "cfg": "{\"command\":\"x\"}", + "error": "nom de serveur vide", + "state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true}}" + }, + { + "op": "set", + "name": "a__b", + "cfg": "{\"command\":\"x\"}", + "error": "le nom ne peut pas contenir '__' (réservé au namespacing des outils)", + "state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true}}" + }, + { + "op": "set", + "name": "both", + "cfg": "{\"command\":\"x\",\"url\":\"http://u\"}", + "error": "un serveur MCP ne peut avoir à la fois 'command' (stdio) et 'url' (http)", + "state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true}}" + }, + { + "op": "set", + "name": "none", + "cfg": "{\"enabled\":true}", + "error": "un serveur MCP doit avoir soit 'command' (stdio) soit 'url' (http)", + "state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true}}" + }, + { + "op": "set", + "name": "web", + "cfg": "{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":false}", + "error": "", + "state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":false}}" + }, + { + "op": "enable", + "name": "web", + "on": true, + "error": "", + "state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true}}" + }, + { + "op": "enable", + "name": "nope", + "on": true, + "error": "serveur MCP inconnu: nope", + "state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true}}" + }, + { + "op": "tool", + "name": "fs", + "tool": "write", + "error": "", + "state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true,\"disabledTools\":[\"write\"]},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true}}" + }, + { + "op": "tool", + "name": "fs", + "tool": "read", + "error": "", + "state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true,\"disabledTools\":[\"write\",\"read\"]},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true}}" + }, + { + "op": "tool", + "name": "fs", + "tool": "write", + "on": true, + "error": "", + "state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true,\"disabledTools\":[\"read\"]},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true}}" + }, + { + "op": "tool", + "name": "fs", + "tool": "read", + "error": "", + "state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true,\"disabledTools\":[\"read\"]},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true}}" + }, + { + "op": "tool", + "name": "nope", + "tool": "x", + "error": "serveur MCP inconnu: nope", + "state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true,\"disabledTools\":[\"read\"]},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true}}" + }, + { + "op": "delete", + "name": "web", + "error": "", + "state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true,\"disabledTools\":[\"read\"]}}" + }, + { + "op": "delete", + "name": "web", + "error": "serveur MCP inconnu: web", + "state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true,\"disabledTools\":[\"read\"]}}" + }, + { + "op": "delete", + "name": " fs", + "error": "serveur MCP inconnu: fs", + "state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true,\"disabledTools\":[\"read\"]}}" + } + ], + "prompt_line": [ + { + "out": "", + "tools": [] + }, + { + "out": "", + "tools": [ + [ + "bash", + "shell" + ] + ] + }, + { + "out": "\n\nMCP servers connected (their tools are prefixed mcp____): a (2), b (1).", + "tools": [ + [ + "mcp__b__x", + "[MCP: b] d" + ], + [ + "mcp__a__y", + "[MCP: a] d" + ], + [ + "mcp__a__z", + "[MCP: a] e" + ] + ] + }, + { + "out": "\n\nMCP servers connected (their tools are prefixed mcp____): a]b (1).", + "tools": [ + [ + "mcp__a_b__x", + "[MCP: a]b] d" + ] + ] + } + ], + "prompt_line_live": "\n\nMCP servers connected (their tools are prefixed mcp____): alpha (10).", + "sanitize": { + "": "", + "...": "", + "A-B_c9": "A-B_c9", + "__x__": "x", + "a.b/c": "a_b_c", + "alpha": "alpha", + "mon serveur!": "mon_serveur", + "é-t_é": "-t" + }, + "schemas": [ + { + "in": "null", + "out": { + "properties": {}, + "type": "object" + } + }, + { + "in": "{\"type\":\"object\",\"properties\":{\"a\":{\"type\":\"string\"}}}", + "out": { + "properties": { + "a": { + "type": "string" + } + }, + "type": "object" + } + }, + { + "in": "true", + "out": { + "properties": {}, + "type": "object" + } + }, + { + "in": "[1]", + "out": { + "properties": {}, + "type": "object" + } + }, + { + "in": "{}", + "out": {} + }, + { + "in": "\"x\"", + "out": { + "properties": {}, + "type": "object" + } + } + ], + "status": [ + { + "name": "alpha", + "transport": "http", + "enabled": true, + "connected": true, + "tools": [ + "big", + "dot.tool", + "echo", + "empty", + "fail", + "link", + "media", + "multi", + "secret", + "struct", + "titled" + ], + "disabled": [ + "secret" + ], + "url": "{{URL}}" + }, + { + "name": "beta", + "transport": "stdio", + "enabled": true, + "connected": false, + "error": "exec: \"loki-mcp-absent-pour-test\": executable file not found in $PATH", + "tools": [], + "disabled": [], + "command": "loki-mcp-absent-pour-test" + }, + { + "name": "delta", + "transport": "", + "enabled": true, + "connected": false, + "tools": [], + "disabled": [] + }, + { + "name": "gamma", + "transport": "http", + "enabled": false, + "connected": false, + "tools": [], + "disabled": [], + "url": "{{URL}}" + } + ], + "store": "{\"alpha\":{\"url\":\"{{URL}}\",\"enabled\":true,\"disabledTools\":[\"secret\"]},\"beta\":{\"command\":\"loki-mcp-absent-pour-test\",\"enabled\":true},\"gamma\":{\"url\":\"{{URL}}\",\"enabled\":false},\"delta\":{\"enabled\":true}}", + "timeouts": { + "": { + "err": "délai dépassé — le serveur n'a pas répondu au handshake en 20s", + "secs": 20 + }, + "/usr/local/bin/NPX": { + "err": "délai dépassé — npx télécharge le paquet du serveur au premier lancement, ce qui peut être long ; clique « tester » pour réessayer (le paquet reste en cache), et vérifie que le conteneur atteint le registre (npm/PyPI)", + "secs": 180 + }, + "C:/bin/uvx.EXE": { + "err": "délai dépassé — uvx télécharge le paquet du serveur au premier lancement, ce qui peut être long ; clique « tester » pour réessayer (le paquet reste en cache), et vérifie que le conteneur atteint le registre (npm/PyPI)", + "secs": 180 + }, + "node": { + "err": "délai dépassé — le serveur n'a pas répondu au handshake en 20s", + "secs": 20 + }, + "npx": { + "err": "délai dépassé — npx télécharge le paquet du serveur au premier lancement, ce qui peut être long ; clique « tester » pour réessayer (le paquet reste en cache), et vérifie que le conteneur atteint le registre (npm/PyPI)", + "secs": 180 + }, + "npx.cmd": { + "err": "délai dépassé — npx télécharge le paquet du serveur au premier lancement, ce qui peut être long ; clique « tester » pour réessayer (le paquet reste en cache), et vérifie que le conteneur atteint le registre (npm/PyPI)", + "secs": 180 + }, + "uvx-x": { + "err": "délai dépassé — le serveur n'a pas répondu au handshake en 20s", + "secs": 20 + }, + "uvx.exe": { + "err": "délai dépassé — uvx télécharge le paquet du serveur au premier lancement, ce qui peut être long ; clique « tester » pour réessayer (le paquet reste en cache), et vérifie que le conteneur atteint le registre (npm/PyPI)", + "secs": 180 + } + }, + "tool_server": [ + { + "desc": "[MCP: alpha] desc", + "name": "mcp__alpha__x", + "ok": true, + "server": "alpha" + }, + { + "desc": "[MCP: a]b] desc", + "name": "mcp__a_b__x", + "ok": true, + "server": "a]b" + }, + { + "desc": "[MCP: a] b] c] d", + "name": "mcp__a_b_c__x", + "ok": true, + "server": "a] b] c" + }, + { + "desc": "pas de préfixe", + "name": "mcp__x__y", + "ok": false, + "server": "" + }, + { + "desc": "[MCP: autre] d", + "name": "autre", + "ok": false, + "server": "" + }, + { + "desc": "[MCP: a] d", + "name": "mcp__b__x", + "ok": false, + "server": "" + } + ], + "tools": [ + { + "type": "function", + "function": { + "name": "mcp__alpha__big", + "description": "[MCP: alpha] Long", + "parameters": { + "type": "object" + } + } + }, + { + "type": "function", + "function": { + "name": "mcp__alpha__dot_tool", + "description": "[MCP: alpha] Nom pointé", + "parameters": { + "properties": {}, + "type": "object" + } + } + }, + { + "type": "function", + "function": { + "name": "mcp__alpha__echo", + "description": "[MCP: alpha] Répète le texte ", + "parameters": { + "properties": { + "text": { + "type": "string" + } + }, + "type": "object" + } + } + }, + { + "type": "function", + "function": { + "name": "mcp__alpha__empty", + "description": "[MCP: alpha] Rien", + "parameters": { + "type": "object" + } + } + }, + { + "type": "function", + "function": { + "name": "mcp__alpha__fail", + "description": "[MCP: alpha] Échoue", + "parameters": { + "type": "object" + } + } + }, + { + "type": "function", + "function": { + "name": "mcp__alpha__link", + "description": "[MCP: alpha] Un lien", + "parameters": { + "type": "object" + } + } + }, + { + "type": "function", + "function": { + "name": "mcp__alpha__media", + "description": "[MCP: alpha] Image et son", + "parameters": { + "type": "object" + } + } + }, + { + "type": "function", + "function": { + "name": "mcp__alpha__multi", + "description": "[MCP: alpha] Deux blocs", + "parameters": { + "type": "object" + } + } + }, + { + "type": "function", + "function": { + "name": "mcp__alpha__struct", + "description": "[MCP: alpha] Structuré", + "parameters": { + "type": "object" + } + } + }, + { + "type": "function", + "function": { + "name": "mcp__alpha__titled", + "description": "[MCP: alpha] Titre seul", + "parameters": { + "type": "object" + } + } + } + ], + "wire": { + "tools/call": { + "big": { + "content": [ + { + "text": "éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", + "type": "text" + } + ] + }, + "dot.tool": { + "content": [ + { + "text": "point", + "type": "text" + } + ] + }, + "echo": { + "content": [ + { + "text": "salut ", + "type": "text" + } + ] + }, + "empty": { + "content": [] + }, + "fail": { + "content": [ + { + "text": "boum", + "type": "text" + } + ], + "isError": true + }, + "link": { + "content": [ + { + "mimeType": "text/plain", + "name": "a.txt", + "size": 12, + "title": "A", + "type": "resource_link", + "uri": "file:///a.txt" + } + ] + }, + "media": { + "content": [ + { + "data": "cG5n", + "mimeType": "image/png", + "type": "image" + }, + { + "data": "d2F2", + "mimeType": "audio/wav", + "type": "audio" + } + ] + }, + "multi": { + "content": [ + { + "text": "un", + "type": "text" + }, + { + "text": "deux", + "type": "text" + } + ] + }, + "secret": { + "content": [ + { + "text": "chut", + "type": "text" + } + ] + }, + "struct": { + "content": [], + "structuredContent": { + "a": " & y", + "b": [ + 1, + 2 + ] + } + }, + "titled": { + "content": [ + { + "text": "ok", + "type": "text" + } + ] + } + }, + "tools/list": { + "tools": [ + { + "description": "Long", + "inputSchema": { + "type": "object" + }, + "name": "big" + }, + { + "description": "Nom pointé", + "inputSchema": { + "properties": {}, + "type": "object" + }, + "name": "dot.tool" + }, + { + "description": "Répète le texte ", + "inputSchema": { + "properties": { + "text": { + "type": "string" + } + }, + "type": "object" + }, + "name": "echo" + }, + { + "description": "Rien", + "inputSchema": { + "type": "object" + }, + "name": "empty" + }, + { + "description": "Échoue", + "inputSchema": { + "type": "object" + }, + "name": "fail" + }, + { + "description": "Un lien", + "inputSchema": { + "type": "object" + }, + "name": "link" + }, + { + "description": "Image et son", + "inputSchema": { + "type": "object" + }, + "name": "media" + }, + { + "description": "Deux blocs", + "inputSchema": { + "type": "object" + }, + "name": "multi" + }, + { + "description": "Masqué", + "inputSchema": { + "type": "object" + }, + "name": "secret" + }, + { + "description": "Structuré", + "inputSchema": { + "type": "object" + }, + "name": "struct" + }, + { + "inputSchema": { + "type": "object" + }, + "name": "titled", + "title": "Titre seul" + } + ] + } + } +} diff --git a/rust/loki-rs/tests/proxy.rs b/rust/loki-rs/tests/proxy.rs index 175e7a2..09bbf47 100644 --- a/rust/loki-rs/tests/proxy.rs +++ b/rust/loki-rs/tests/proxy.rs @@ -110,6 +110,7 @@ async fn facade(backend: SocketAddr) -> (SocketAddr, oneshot::Sender<()>) { engine_ctl: None, system: None, projects: false, + mcp: None, }; tokio::spawn(loki_rs::proxy::serve(l, cfg, async move { let _ = rx.await;