Files
Loki/internal/loki/mcp_catalog.go
T
MichaelandClaude Opus 5 847aad217b MCP : catalogue embarqué de serveurs connus
Ajouter un serveur MCP demandait d'écrire soi-même `npx -y @scope/paquet …`
dans la modale, en devinant le nom du paquet. Le catalogue liste une vingtaine
de serveurs connus, commande déjà renseignée, classés par catégorie.

- internal/loki/mcp_catalog.json est EMBARQUÉ dans le binaire (go:embed), pas
  interrogé sur le réseau : Loki tourne hors ligne, et rien de ce qui s'exécute
  sur la machine ne vient d'un annuaire distant. Pour proposer un serveur de
  plus : éditer le fichier et recompiler.
- Choisir une entrée n'installe rien. Ça préremplit la modale d'ajout existante
  et l'utilisateur relit la commande avant d'enregistrer — un serveur stdio
  exécute un process arbitraire, au même titre que l'outil bash du mode agent.
  Aucune ligne de mcp_client.go n'est touchée : le catalogue s'arrête à
  l'affichage, tout le reste passe par l'API MCP déjà en place.
- Une entrée dont le runtime manque (npx ou uvx absent) le signale dans la
  liste, plutôt que de laisser l'utilisateur découvrir l'échec au démarrage du
  serveur.
- Les entrées qui réclament une clé d'API la rappellent avant l'enregistrement,
  au lieu d'enregistrer un serveur qui ne peut pas se connecter.
- Les entrées Python publiées avant le SDK MCP 2.0 sont épinglées avec
  `uvx --with "mcp<2"` : sans cela elles plantent sur ImportError: McpError.
- Le Dockerfile gagne uv/uvx (~35 Mo). Sans lui, 9 des 21 entrées s'affichent
  sans pouvoir démarrer dans le conteneur.

Intensité du raisonnement (REASONING_EFFORT)

L'interrupteur Raisonnement était binaire. llama-server accepte
`reasoning_effort` dans /v1/chat/completions : `none` coupe le raisonnement,
toute autre valeur est passée au gabarit jinja du modèle.

- Nouvelle clé de preset REASONING_EFFORT — donc réglée par modèle, comme le
  reste du preset. Vide = on n'envoie rien et le gabarit garde son comportement.
- Pas de repli à prévoir : un gabarit qui ne lit pas la valeur l'ignore sans
  erreur. En pratique seuls gpt-oss et apparentés changent de comportement, et
  le sous-titre du réglage le dit — promettre un effet universel serait faux.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 10:06:14 +02:00

158 lines
4.8 KiB
Go

package loki
// Catalogue MCP embarqué.
//
// Ajouter un serveur MCP demandait jusqu'ici d'écrire soi-même la commande et
// ses arguments (`npx -y @scope/paquet …`) dans la modale. Le catalogue fournit
// une liste de serveurs connus, déjà renseignés : un clic préremplit la modale,
// l'utilisateur relit la commande et enregistre.
//
// Le catalogue est un JSON EMBARQUÉ dans le binaire (go:embed), pas un appel à
// un annuaire distant : cohérent avec un Loki qui tourne hors ligne, et rien de
// ce qui s'exécute sur la machine ne vient du réseau. Pour proposer un serveur
// de plus : éditer mcp_catalog.json et recompiler.
//
// SÉCURITÉ : le catalogue ne fait qu'AFFICHER une commande. Il n'installe ni
// n'exécute rien tout seul — c'est l'enregistrement du serveur par
// l'utilisateur, via la modale existante, qui décide. Même niveau de confiance
// que l'outil `bash` du mode agent (voir l'en-tête de mcp_config.go).
import (
_ "embed"
"encoding/json"
"fmt"
"os/exec"
"strings"
"sync"
)
//go:embed mcp_catalog.json
var mcpCatalogRaw []byte
// MCPCatalogEntry décrit un serveur proposé par le catalogue. Les champs de
// transport reprennent ceux de MCPServerConfig : l'UI peut les recopier tels
// quels dans la modale d'ajout.
type MCPCatalogEntry struct {
ID string `json:"id"`
Name string `json:"name"`
Category string `json:"category"`
Description string `json:"description"`
Homepage string `json:"homepage,omitempty"`
// Runtime : binaire nécessaire pour lancer le serveur ("node" → npx,
// "python" → uvx). Sert à signaler une entrée non installable ici.
Runtime string `json:"runtime,omitempty"`
Command string `json:"command"`
Args []string `json:"args,omitempty"`
Env map[string]string `json:"env,omitempty"`
// Secrets : variables d'environnement que l'utilisateur DOIT renseigner
// (jetons, clés d'API). L'UI les met en évidence ; elles sont volontairement
// vides dans le catalogue.
Secrets []string `json:"secrets,omitempty"`
// Renseignés à la lecture, pas dans le fichier.
Installed bool `json:"installed"`
RuntimeOK bool `json:"runtimeOk"`
}
type mcpCatalogFile struct {
Version int `json:"version"`
Servers []MCPCatalogEntry `json:"servers"`
}
var (
mcpCatalogOnce sync.Once
mcpCatalogParsed []MCPCatalogEntry
mcpCatalogParseEr error
)
// mcpCatalogEntries décode le JSON embarqué une seule fois. Le contenu est figé
// dans le binaire : inutile de le relire à chaque appel.
func mcpCatalogEntries() ([]MCPCatalogEntry, error) {
mcpCatalogOnce.Do(func() {
var f mcpCatalogFile
if err := json.Unmarshal(mcpCatalogRaw, &f); err != nil {
mcpCatalogParseEr = fmt.Errorf("catalogue MCP illisible : %w", err)
return
}
mcpCatalogParsed = f.Servers
})
return mcpCatalogParsed, mcpCatalogParseEr
}
// mcpRuntimeAvailable indique si le binaire qui lance ce type de serveur est
// présent. L'image conteneur embarque Node ; uv/uvx peut manquer.
func mcpRuntimeAvailable(runtime string) bool {
switch runtime {
case "node":
return lookPathOK("npx")
case "python":
return lookPathOK("uvx")
default:
return true
}
}
func lookPathOK(bin string) bool {
_, err := exec.LookPath(bin)
return err == nil
}
// MCPCatalog renvoie les entrées du catalogue, chemins résolus et marquées
// « déjà installée » (un serveur configuré porte le même nom que l'entrée).
func MCPCatalog() ([]MCPCatalogEntry, error) {
entries, err := mcpCatalogEntries()
if err != nil {
return nil, err
}
configured, err := LoadMCPConfig()
if err != nil {
// Une config MCP illisible ne doit pas vider le catalogue : on affiche
// les entrées sans savoir lesquelles sont déjà là.
configured = map[string]MCPServerConfig{}
}
out := make([]MCPCatalogEntry, 0, len(entries))
for _, e := range entries {
e.Args = expandCatalogSlice(e.Args)
e.Env = expandCatalogMap(e.Env)
_, e.Installed = configured[e.ID]
e.RuntimeOK = mcpRuntimeAvailable(e.Runtime)
out = append(out, e)
}
return out, nil
}
// expandCatalogToken remplace les jetons de chemin du catalogue. Seul
// {workspace} existe : c'est le dossier de travail de l'agent, le seul chemin
// qu'un serveur du catalogue a une raison légitime de recevoir par défaut.
func expandCatalogToken(s string) string {
if !strings.Contains(s, "{workspace}") {
return s
}
return strings.ReplaceAll(s, "{workspace}", workspaceDir())
}
func expandCatalogSlice(in []string) []string {
if len(in) == 0 {
return in
}
out := make([]string, len(in))
for i, s := range in {
out[i] = expandCatalogToken(s)
}
return out
}
func expandCatalogMap(in map[string]string) map[string]string {
if len(in) == 0 {
return in
}
out := make(map[string]string, len(in))
for k, v := range in {
out[k] = expandCatalogToken(v)
}
return out
}