From 847aad217bfec5d41afc1ceb586e40beb2fe27cb Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Mon, 17 Aug 2026 10:06:14 +0200 Subject: [PATCH] =?UTF-8?q?MCP=20:=20catalogue=20embarqu=C3=A9=20de=20serv?= =?UTF-8?q?eurs=20connus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Dockerfile | 5 + README.md | 20 +- internal/loki/backend_models.go | 28 +++ internal/loki/llm_client.go | 9 +- internal/loki/mcp_catalog.go | 157 ++++++++++++++++ internal/loki/mcp_catalog.json | 262 +++++++++++++++++++++++++++ internal/loki/mcp_catalog_test.go | 81 +++++++++ internal/loki/sys_service.go | 1 + internal/loki/ui/index.html | 171 ++++++++++++++++- internal/loki/ui/src/index.tmpl.html | 34 +++- internal/loki/ui/src/js/07-models.js | 14 ++ internal/loki/ui/src/js/12-mcp.js | 112 ++++++++++++ internal/loki/ui/src/styles.css | 11 ++ internal/loki/web_api.go | 12 ++ internal/loki/web_server.go | 1 + 15 files changed, 914 insertions(+), 4 deletions(-) create mode 100644 internal/loki/mcp_catalog.go create mode 100644 internal/loki/mcp_catalog.json create mode 100644 internal/loki/mcp_catalog_test.go diff --git a/Dockerfile b/Dockerfile index f0b3417..975fff4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,6 +43,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && apt-get install -y --no-install-recommends nodejs \ && rm -rf /var/lib/apt/lists/* +# uv/uvx : lanceur des serveurs MCP écrits en Python (mcp-server-git, -fetch, +# -time, sqlite, docker…). Sans lui, une bonne partie du catalogue embarqué +# s'affiche mais ne peut pas démarrer. Deux binaires statiques, ~35 Mo. +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/ + # Playwright + Chromium : l'outil web_screenshot capture une page RENDUE # (JavaScript exécuté), ce que le moteur web intégré ne sait pas faire. # Coût assumé : ~500 Mo à 1 Go d'image. Mettre PLAYWRIGHT=0 pour bâtir une image diff --git a/README.md b/README.md index 8c8e87a..b1184cc 100644 --- a/README.md +++ b/README.md @@ -154,13 +154,31 @@ Héritées d'AJEAN : - **Accès internet** : recherche + lecture de pages, moteur Go intégré ou [Crawl4AI](https://github.com/unclecode/crawl4ai) pour les pages JS. - **Agent** : shell, fichiers, workspace (`agent on`). -- **Serveurs MCP** : Node.js est inclus dans l'image pour les serveurs `npx`. +- **Serveurs MCP** : Node.js (`npx`) et uv (`uvx`) sont inclus dans l'image, pour + les serveurs écrits en JavaScript comme en Python. - **Presets** de configuration par modèle, bench, auto-détection GPU. - **API OpenAI-compatible** exposable, protégée par clé (voir ci-dessous — ce fork la sert autrement que l'amont). Ajoutées par ce fork : +- **Catalogue MCP** : le panneau *Serveurs MCP* offre un bouton **catalogue** — + une vingtaine de serveurs connus (filesystem, git, fetch, memory, sqlite, + playwright, context7, github…) avec leur commande déjà renseignée, classés par + catégorie. Choisir une entrée **n'installe rien** : ça remplit le formulaire + d'ajout, tu relis la commande — qui s'exécutera sur cette machine — puis tu + enregistres. Le catalogue est un JSON **embarqué dans le binaire** + (`internal/loki/mcp_catalog.json`), donc aucun appel à un annuaire distant : + pour en proposer d'autres, édite ce fichier et recompile. Une entrée dont le + runtime manque (`npx`/`uvx` absent) le signale au lieu d'échouer plus tard, et + celles qui réclament une clé d'API la rappellent avant l'enregistrement. +- **Intensité du raisonnement** : en plus de l'interrupteur *Raisonnement*, + l'éditeur de preset expose un niveau — auto / aucune / basse / moyenne / haute + — envoyé à `llama-server` comme `reasoning_effort`. Réglage **par preset**, + donc de fait par modèle. `none` coupe le raisonnement ; les autres valeurs sont + passées au gabarit jinja du modèle, ce qui ne change le comportement que des + modèles qui les lisent (gpt-oss et apparentés) — ailleurs c'est ignoré sans + erreur, et le sous-titre du réglage le dit plutôt que de promettre un effet. - **Discussions multiples** : historique complet dans la barre latérale, titre repris du premier message (renommable), suppression. **Chaque discussion a son dossier de fichiers** (`workspace/discussions//`) : les pièces jointes diff --git a/internal/loki/backend_models.go b/internal/loki/backend_models.go index d59dd7d..08793b3 100644 --- a/internal/loki/backend_models.go +++ b/internal/loki/backend_models.go @@ -70,6 +70,34 @@ func reasoningActive(v string) bool { return true } +// ReasoningEfforts liste les valeurs proposées par l'UI pour REASONING_EFFORT. +// "" (auto) n'y figure pas : c'est l'absence de réglage. +var ReasoningEfforts = []string{"none", "low", "medium", "high"} + +// reasoningEffortValue normalise REASONING_EFFORT en une valeur envoyable telle +// quelle à llama-server, ou "" pour ne rien envoyer du tout. +// +// llama-server accepte `reasoning_effort` dans /v1/chat/completions : la valeur +// `none` coupe le raisonnement, toute autre valeur est simplement passée au +// gabarit jinja du modèle. Un gabarit qui ne la lit pas l'ignore sans erreur — +// c'est pourquoi il n'y a pas de repli à prévoir ici, contrairement à un backend +// qui rejetterait la requête. En pratique seuls les modèles dont le gabarit +// gère `reasoning_effort` (gpt-oss et apparentés) changent de comportement. +func reasoningEffortValue(v string) string { + switch s := strings.ToLower(strings.TrimSpace(v)); s { + case "", "auto", "default": + return "" // rien envoyé : le gabarit garde son comportement + case "off", "false", "0", "no", "disable", "disabled", "none": + return "none" + case "low", "medium", "high": + return s + default: + // Valeur inconnue (éditée à la main) : on ne l'invente pas, on la laisse + // passer au gabarit, qui l'ignorera s'il ne la connaît pas. + return s + } +} + // detectQuant returns the quantization tag for a preset: an explicit QUANT= line // (manual override, with or without a leading '#') wins; otherwise it is // auto-detected from the MODEL= filename. Returns "" when unknown. diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go index 424ad9a..709e003 100644 --- a/internal/loki/llm_client.go +++ b/internal/loki/llm_client.go @@ -574,7 +574,11 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps // reasoning is enabled we split that out ourselves so the UI's reasoning // bubble works regardless of backend. The ik_llama.cpp fork already sends // reasoning_content, in which case we leave content untouched. - reasoningOn := reasoningActive(ReadConfig()["REASONING"]) + chatCfg := ReadConfig() + reasoningOn := reasoningActive(chatCfg["REASONING"]) + // Intensité du raisonnement, passée telle quelle au gabarit du modèle. Vide + // = on n'envoie rien. Réglage par preset, donc de fait par modèle. + reasoningEffort := reasoningEffortValue(chatCfg["REASONING_EFFORT"]) // When llama.cpp fails to parse a model-generated tool call (HTTP 500), we // retry the same turn once with tools removed so the model answers in plain // text from the tool results already gathered, instead of dying mid-chat. @@ -611,6 +615,9 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps // contexte (sinon le system prompt déjà en cache n'est pas recompté). "stream_options": map[string]any{"include_usage": true}, } + if reasoningEffort != "" { + payload["reasoning_effort"] = reasoningEffort + } if len(tools) > 0 && !disableTools { payload["tools"] = tools // The model sometimes emits parallel tool calls, which this llama.cpp diff --git a/internal/loki/mcp_catalog.go b/internal/loki/mcp_catalog.go new file mode 100644 index 0000000..bba5c46 --- /dev/null +++ b/internal/loki/mcp_catalog.go @@ -0,0 +1,157 @@ +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 +} diff --git a/internal/loki/mcp_catalog.json b/internal/loki/mcp_catalog.json new file mode 100644 index 0000000..55d3faf --- /dev/null +++ b/internal/loki/mcp_catalog.json @@ -0,0 +1,262 @@ +{ + "version": 1, + "note": "Catalogue MCP embarqué (hors ligne, aucun annuaire distant). Éditable à la main : ajoute une entrée pour la proposer dans l'interface, puis relance le build. Le jeton {workspace} est remplacé par le dossier de travail de l'agent. Les entrées « uvx --with mcp<2 … » visent des serveurs publiés avant le SDK MCP 2.0 : sans cet épinglage, ils plantent au démarrage (ImportError McpError).", + "servers": [ + { + "id": "filesystem", + "name": "Filesystem", + "category": "Fichiers", + "description": "Lecture, écriture et recherche de fichiers, limitées aux dossiers passés en argument.", + "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem", + "runtime": "node", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "{workspace}"], + "env": {}, + "secrets": [] + }, + { + "id": "git", + "name": "Git", + "category": "Code", + "description": "Historique, diff et statut d'un dépôt Git ; commits et branches.", + "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/git", + "runtime": "python", + "command": "uvx", + "args": ["mcp-server-git", "--repository", "{workspace}"], + "env": {}, + "secrets": [] + }, + { + "id": "fetch", + "name": "Fetch", + "category": "Web", + "description": "Télécharge une URL et la convertit en Markdown pour le modèle.", + "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/fetch", + "runtime": "python", + "command": "uvx", + "args": ["--with", "mcp<2", "mcp-server-fetch"], + "env": {}, + "secrets": [] + }, + { + "id": "memory", + "name": "Memory", + "category": "Agent", + "description": "Mémoire sous forme de graphe de connaissances (entités, relations, observations).", + "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/memory", + "runtime": "node", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-memory"], + "env": { "MEMORY_FILE_PATH": "{workspace}/mcp-memory.json" }, + "secrets": [] + }, + { + "id": "sequential-thinking", + "name": "Sequential Thinking", + "category": "Agent", + "description": "Structure le raisonnement en étapes révisables. Utile aux petits modèles locaux.", + "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/sequentialthinking", + "runtime": "node", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-sequential-thinking"], + "env": {}, + "secrets": [] + }, + { + "id": "time", + "name": "Time", + "category": "Utilitaires", + "description": "Heure courante et conversions de fuseaux horaires.", + "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/time", + "runtime": "python", + "command": "uvx", + "args": ["--with", "mcp<2", "mcp-server-time"], + "env": {}, + "secrets": [] + }, + { + "id": "sqlite", + "name": "SQLite", + "category": "Données", + "description": "Interroge et modifie une base SQLite locale.", + "homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/sqlite", + "runtime": "python", + "command": "uvx", + "args": ["--with", "mcp<2", "mcp-server-sqlite", "--db-path", "{workspace}/agent.db"], + "env": {}, + "secrets": [] + }, + { + "id": "everything", + "name": "Everything (test)", + "category": "Utilitaires", + "description": "Serveur de référence exposant toutes les primitives MCP. Sert à vérifier l'intégration.", + "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/everything", + "runtime": "node", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-everything"], + "env": {}, + "secrets": [] + }, + { + "id": "duckduckgo", + "name": "DuckDuckGo", + "category": "Web", + "description": "Recherche web via DuckDuckGo, sans clé d'API.", + "homepage": "https://pypi.org/project/duckduckgo-mcp-server/", + "runtime": "python", + "command": "uvx", + "args": ["duckduckgo-mcp-server"], + "env": {}, + "secrets": [] + }, + { + "id": "playwright", + "name": "Playwright", + "category": "Web", + "description": "Pilote un navigateur (navigation, clics, formulaires) via l'arbre d'accessibilité.", + "homepage": "https://github.com/microsoft/playwright-mcp", + "runtime": "node", + "command": "npx", + "args": ["-y", "@playwright/mcp@latest", "--headless"], + "env": {}, + "secrets": [] + }, + { + "id": "puppeteer", + "name": "Puppeteer", + "category": "Web", + "description": "Automatisation d'un Chrome sans interface (alternative à Playwright).", + "homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/puppeteer", + "runtime": "node", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-puppeteer"], + "env": {}, + "secrets": [] + }, + { + "id": "context7", + "name": "Context7", + "category": "Code", + "description": "Documentation à jour des bibliothèques, injectée directement dans le contexte.", + "homepage": "https://github.com/upstash/context7", + "runtime": "node", + "command": "npx", + "args": ["-y", "@upstash/context7-mcp"], + "env": {}, + "secrets": [] + }, + { + "id": "github", + "name": "GitHub", + "category": "Code", + "description": "Dépôts, issues et pull requests via l'API GitHub. Nécessite un jeton personnel.", + "homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/github", + "runtime": "node", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"], + "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "" }, + "secrets": ["GITHUB_PERSONAL_ACCESS_TOKEN"] + }, + { + "id": "postgres", + "name": "PostgreSQL", + "category": "Données", + "description": "Requêtes SQL en lecture seule et inspection du schéma d'une base PostgreSQL.", + "homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/postgres", + "runtime": "node", + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-postgres", + "postgresql://utilisateur:motdepasse@hote:5432/base" + ], + "env": {}, + "secrets": [] + }, + { + "id": "docker", + "name": "Docker", + "category": "Infra", + "description": "Gère conteneurs et stacks compose. Nécessite l'accès au socket Docker.", + "homepage": "https://pypi.org/project/docker-mcp/", + "runtime": "python", + "command": "uvx", + "args": ["--with", "mcp<2", "docker-mcp"], + "env": {}, + "secrets": [] + }, + { + "id": "brave-search", + "name": "Brave Search", + "category": "Web", + "description": "Recherche web et locale via l'API Brave. Nécessite une clé d'API.", + "homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/brave-search", + "runtime": "node", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-brave-search"], + "env": { "BRAVE_API_KEY": "" }, + "secrets": ["BRAVE_API_KEY"] + }, + { + "id": "firecrawl", + "name": "Firecrawl", + "category": "Web", + "description": "Scraping et crawl de sites avec extraction structurée. Nécessite une clé d'API.", + "homepage": "https://github.com/firecrawl/firecrawl-mcp-server", + "runtime": "node", + "command": "npx", + "args": ["-y", "firecrawl-mcp"], + "env": { "FIRECRAWL_API_KEY": "" }, + "secrets": ["FIRECRAWL_API_KEY"] + }, + { + "id": "notion", + "name": "Notion", + "category": "Productivité", + "description": "Lecture et écriture de pages et bases Notion. Nécessite un jeton d'intégration.", + "homepage": "https://github.com/makenotion/notion-mcp-server", + "runtime": "node", + "command": "npx", + "args": ["-y", "@notionhq/notion-mcp-server"], + "env": { "NOTION_TOKEN": "" }, + "secrets": ["NOTION_TOKEN"] + }, + { + "id": "slack", + "name": "Slack", + "category": "Productivité", + "description": "Lecture des canaux et envoi de messages Slack. Nécessite un jeton de bot.", + "homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/slack", + "runtime": "node", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-slack"], + "env": { "SLACK_BOT_TOKEN": "", "SLACK_TEAM_ID": "" }, + "secrets": ["SLACK_BOT_TOKEN"] + }, + { + "id": "google-maps", + "name": "Google Maps", + "category": "Utilitaires", + "description": "Géocodage, itinéraires et recherche de lieux. Nécessite une clé d'API.", + "homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/google-maps", + "runtime": "node", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-google-maps"], + "env": { "GOOGLE_MAPS_API_KEY": "" }, + "secrets": ["GOOGLE_MAPS_API_KEY"] + }, + { + "id": "sentry", + "name": "Sentry", + "category": "Infra", + "description": "Récupère les issues et traces d'erreur d'un projet Sentry.", + "homepage": "https://pypi.org/project/mcp-server-sentry/", + "runtime": "python", + "command": "uvx", + "args": ["--with", "mcp<2", "mcp-server-sentry", "--auth-token", "REMPLACE_MOI"], + "env": {}, + "secrets": [] + } + ] +} diff --git a/internal/loki/mcp_catalog_test.go b/internal/loki/mcp_catalog_test.go new file mode 100644 index 0000000..20c086e --- /dev/null +++ b/internal/loki/mcp_catalog_test.go @@ -0,0 +1,81 @@ +package loki + +import ( + "strings" + "testing" +) + +// Le catalogue est embarqué : s'il ne décode pas, le binaire livré est cassé et +// le panneau MCP part vide. Ce test attrape une virgule en trop au build. +func TestMCPCatalogDecodes(t *testing.T) { + entries, err := mcpCatalogEntries() + if err != nil { + t.Fatalf("catalogue illisible : %v", err) + } + if len(entries) == 0 { + t.Fatal("catalogue vide") + } + + seen := map[string]bool{} + for _, e := range entries { + if e.ID == "" || e.Name == "" || e.Command == "" { + t.Errorf("entrée incomplète : %+v", e) + } + if seen[e.ID] { + t.Errorf("id dupliqué : %s", e.ID) + } + seen[e.ID] = true + if e.Description == "" { + t.Errorf("%s : description manquante", e.ID) + } + // Un secret annoncé doit exister comme variable d'environnement, sinon + // l'UI met en avant un champ que l'utilisateur ne peut pas remplir. + for _, s := range e.Secrets { + if _, ok := e.Env[s]; !ok { + t.Errorf("%s : secret %q absent de env", e.ID, s) + } + } + } +} + +// Les jetons {workspace} doivent disparaître : un serveur lancé avec un chemin +// littéral « {workspace} » démarre puis échoue à la première lecture. +func TestMCPCatalogExpandsWorkspaceToken(t *testing.T) { + entries, err := MCPCatalog() + if err != nil { + t.Fatalf("MCPCatalog : %v", err) + } + for _, e := range entries { + for _, a := range e.Args { + if strings.Contains(a, "{workspace}") { + t.Errorf("%s : jeton non résolu dans args : %s", e.ID, a) + } + } + for k, v := range e.Env { + if strings.Contains(v, "{workspace}") { + t.Errorf("%s : jeton non résolu dans env[%s] : %s", e.ID, k, v) + } + } + } +} + +func TestReasoningEffortValue(t *testing.T) { + cases := map[string]string{ + "": "", + " ": "", + "auto": "", + "default": "", + "off": "none", + "none": "none", + "false": "none", + "0": "none", + "low": "low", + "MEDIUM": "medium", + " High ": "high", + } + for in, want := range cases { + if got := reasoningEffortValue(in); got != want { + t.Errorf("reasoningEffortValue(%q) = %q, attendu %q", in, got, want) + } + } +} diff --git a/internal/loki/sys_service.go b/internal/loki/sys_service.go index 4eadd6b..0eeb884 100644 --- a/internal/loki/sys_service.go +++ b/internal/loki/sys_service.go @@ -75,6 +75,7 @@ var configTemplate = []struct{ key, help string }{ {"KV_TYPE", "quantization du cache KV (q8_0, q4_0…) ; KV_TYPE_K / KV_TYPE_V pour les séparer"}, {"REASONING", "passthrough du mode raisonnement (on/auto/deepseek)"}, {"REASONING_BUDGET", "plafond de tokens de réflexion ; -1 = illimité"}, + {"REASONING_EFFORT", "intensité du raisonnement : vide (auto) / none / low / medium / high"}, {"COMPACT", "compactage automatique du contexte (off pour couper)"}, {"MEM_MODE", "mémoire de l'IA : off / ondemand / always"}, {"EXTRA_ARGS", "ajouté tel quel à la ligne de commande de llama-server"}, diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index bd64799..7102132 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -1038,6 +1038,17 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5} .mcp-edit{flex:none;padding:2px 5px;font-size:13px;line-height:1;background:transparent;border:none;opacity:.5} .mcp-edit:hover{opacity:1;color:var(--accent)} /* Liste des outils MCP dans la modale */ +.mcp-actions{display:flex;gap:6px;margin-top:6px} +.mcp-cat-tabs{display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px} +.mcp-cat{font-size:11px;padding:2px 9px;border-radius:9px;border:1px solid var(--border);background:transparent;color:var(--dim);cursor:pointer} +.mcp-cat:hover{border-color:var(--accent);color:var(--text)} +.mcp-cat.on{border-color:var(--accent);color:var(--accent)} +.mcp-cat-list{display:flex;flex-direction:column;gap:4px;max-height:52vh;overflow:auto} +.mcp-cat-row{display:flex;align-items:center;gap:8px;padding:7px 10px;border:1px solid var(--border);border-radius:6px;background:var(--panel)} +.mcp-cat-row:hover{border-color:var(--accent)} +.mcp-cat-desc{font-size:11px;color:var(--dim);margin-top:2px;line-height:1.35} +.mcp-cat-cmd{font-size:10px;color:var(--dim);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:340px} +.mcp-cat-use{flex:none} .mcp-tools-list{display:flex;flex-direction:column;gap:2px;max-height:240px;overflow:auto;border:1px solid var(--border);border-radius:6px;padding:6px} .mcp-tool-row{display:flex;align-items:center;gap:10px;padding:4px 6px;border-radius:5px;cursor:pointer} .mcp-tool-row:hover{background:var(--panel)} @@ -2044,7 +2055,10 @@ html[data-files="1"] #files-btn{color:var(--accent)}
(aucun serveur — ajoute un serveur fichiers, une base de données, une API…)
- +
+ + +
@@ -2450,6 +2464,23 @@ html[data-files="1"] #files-btn{color:var(--accent)} + @@ -574,6 +577,23 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid +