b7151b3bc7
Le serveur traite les tools/call en concurrence. Les trois services a cache chargent paresseusement sans se coordonner : le premier appelant qui trouve le cache invalide lance le fetch, et tous ceux qui arrivent pendant ce fetch le trouvent *encore* invalide et lancent le leur. Une rafale de 6 appels identiques declenchait donc 6 chargements complets pour une seule cle. Ce n'est pas qu'un gaspillage : la duplication surcharge l'API AD au point de la faire echouer. Rafale mixte de 14 appels, avant correction — les 4 appels EasyWMS (~4000 workflows) reviennent en erreur, les memes passent en sequentiel : [Workflow] Error fetching workflows for "EasyWMS": POST https://10.255.255.2/AD/api/Workflow/GetByApplication failed (HTTP 500) fetching from API: 8 | EntityResolver Cache expired or empty: 3 Motif commun extrait dans src/services/single-flight.js — une Map de promesses, pas de dependance externe. Une cle par entree de cache (workflows::<app>, applications, <app>::<type>, metadata) : deux cles distinctes se chargent toujours en parallele, aucun prechargement (D26 intact). La promesse est retiree au reglement, succes *ou* echec, pour qu'un fetch en erreur ne reste pas coince. Le log de fetch reste l'observable (un par chargement reel) ; les appelants joints emettent une ligne distincte "Fetch already in flight ... joining it". --- Verifications (LIMAGRAIN), rafales rejouees 3 fois --- Phase 0, reproduction avant correction : 6 x search_workflows CustomApp -> count 44 x6, 'fetching from API' : 6 6 x query_wms_entities Container -> 6 succes, 'EntityResolver] Cache expired or empty' : 6 Rafale de 6 search_workflows {"query":"CST_","application":"CustomApp"} : ===== RUN 1 ===== ===== RUN 2 ===== ===== RUN 3 ===== id 10 success=true application=CustomApp count=44 (idem RUN 2 et RUN 3, id 11 success=true application=CustomApp count=44 les 6 reponses a 44) id 12 success=true application=CustomApp count=44 id 13 success=true application=CustomApp count=44 id 14 success=true application=CustomApp count=44 id 15 success=true application=CustomApp count=44 -- 'fetching from API' : 1 | 'joining it' : 5 [RUN 1] -- 'fetching from API' : 1 | 'joining it' : 5 [RUN 2] -- 'fetching from API' : 1 | 'joining it' : 5 [RUN 3] Rafale de 6 query_wms_entities {"entity_type":"Container","limit":1} : RUN 1/2/3 : id 10..15 success=true count=1 (6/6) -- 'EntityResolver] Cache expired or empty' : 1 | joins : 5 | GET Metadata/Entities : 5 [identique RUN 1, RUN 2, RUN 3] (avant : 6 chargements, soit 30 GET Metadata) Rafale mixte EasyWMS + CustomApp (3 + 3) — un fetch par application : RUN 1/2/3 : CustomApp count=44 x3, EasyWMS count=50 x3 [Workflow] Cache expired or empty for "CustomApp", fetching from API... [Workflow] Cache expired or empty for "EasyWMS", fetching from API... total fetch=2 joins=4 [identique RUN 1, RUN 2, RUN 3] Plus aucun HTTP 500 : un seul fetch EasyWMS concurrent au lieu de 4. Chemin sequentiel nominal, strictement inchange (driver sequentiel) : [search_workflows] success=true application=EasyWMS count=50 len=14220 [search_workflows] success=true application=EasyWMS count=50 len=14220 --- fetch=1 cached=1 joins=0 Liberation de la Map sur echec (test direct, apiService.post substitue : echoue au 1er appel, reussit ensuite) : [Workflow] Cache expired or empty for "TestApp", fetching from API... [Workflow] Fetch already in flight for "workflows::TestApp", joining it (x2) --- rafale de 3 sur un fetch en echec : appelant 0/1/2: rejected - Failed to fetch workflows ... panne reseau simulee appels reseau reels: 1 (attendu 1 : les 3 partagent le meme fetch) cache pose ? {} (attendu {} : rien en cache sur echec) --- appel suivant (la Map doit avoir ete liberee) : resultat: 1 workflow(s), appels reseau cumules: 2 Baseline : tools/list 23, resources/list 6 ; npm test 4/4 exit 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
696 lines
33 KiB
Markdown
696 lines
33 KiB
Markdown
# Décisions d'architecture et pièges vérifiés
|
||
|
||
Ce fichier consigne **pourquoi** le code est écrit comme il l'est. Chaque entrée
|
||
décrit une décision prise ou un piège constaté **sur un WMS réel** — pas une
|
||
supposition. Avant de « corriger » un comportement qui paraît étrange, cherchez-le
|
||
ici : il est probablement volontaire.
|
||
|
||
Convention : une décision reste dans le fichier même si elle est révisée ; on
|
||
ajoute alors une entrée `Révisée le …` plutôt que de réécrire l'histoire.
|
||
|
||
---
|
||
|
||
## D1 — 100 % API, aucun accès Oracle direct
|
||
|
||
**Décision.** Toutes les données transitent par les API REST du WMS. Aucune
|
||
connexion base de données.
|
||
|
||
**Pourquoi.** Le serveur MCP doit fonctionner depuis un poste ou une VM sans
|
||
credentials Oracle, sans client Oracle installé, et sans risque d'écriture
|
||
directe en base. L'API impose en prime les règles métier et les droits du
|
||
compte utilisé.
|
||
|
||
**Conséquence.** Les fichiers `src/services/oracle-service.js`,
|
||
`src/resources/database.js` et `src/tools/database-tools.js` ont été supprimés
|
||
(ils étaient de toute façon morts : non branchés, `oracledb` n'était même pas
|
||
une dépendance). **Ne pas les réintroduire.** Si une donnée n'est pas
|
||
atteignable par API, elle est hors périmètre — voir D16.
|
||
|
||
---
|
||
|
||
## D2 — OAuth : `tenant_code` est obligatoire
|
||
|
||
**Piège.** L'endpoint `/EasySTS/OAuth/Token` répond `400 Bad Request` si le
|
||
paramètre `tenant_code` est absent, sans message explicite.
|
||
|
||
**Solution.** Le corps du grant `password` contient toujours les quatre
|
||
paramètres :
|
||
|
||
```
|
||
grant_type=password&tenant_code=<TENANT>&username=<USER>&password=<PASS>
|
||
```
|
||
|
||
Voir `src/services/api-service.js`, méthode `authenticate()`.
|
||
|
||
---
|
||
|
||
## D3 — `QueryType: 0` (Reading), pas 1
|
||
|
||
**Piège.** `QueryType` (type `QueryContextType`) sélectionne le contexte de
|
||
données interrogé. Il a **quatre** valeurs, pas deux — vérifiées une à une sur
|
||
le tenant `LIMAGRAI2512` :
|
||
|
||
| Valeur | Contexte | Statut sur ce tenant |
|
||
|---|---|---|
|
||
| `0` | **Reading** — `ApplicationReadingContext` | opérationnel, champs de statut en **chaînes** (`"Release"`) |
|
||
| `1` | Writing — `ApplicationWritingRepository` | opérationnel, champs de statut en **énumérations** |
|
||
| `2` | DataWarehouse | **non configuré** : `Could not resolve serviceType 'IDataWarehouse…'` |
|
||
| `3` | Metrics — `ApplicationMetricDataContext` | contexte présent, modèle de données non exploré |
|
||
|
||
Les comparaisons de statut par chaîne — de loin le cas le plus courant en
|
||
debug — **échouent** en `QueryType: 1`. Le code force donc `0` dans
|
||
`executeQuery()` et `executeScalarQuery()`.
|
||
|
||
Le message d'erreur nomme le contexte (`ApplicationReadingContext`,
|
||
`ApplicationWritingRepository`, …) : c'est le moyen le plus rapide de savoir
|
||
quel `QueryType` a réellement été utilisé.
|
||
|
||
**Attention.** D'anciens exemples (dont le PHP de référence) utilisent `1`. Ne
|
||
les recopiez pas.
|
||
|
||
---
|
||
|
||
## D4 — Les API AD renvoient `{ entities: [...] }`, pas un tableau
|
||
|
||
**Piège.** `POST /AD/api/{Type}/GetByApplication` renvoie un objet enveloppe. Un
|
||
code qui traite la réponse comme un tableau obtient silencieusement
|
||
`0 élément` — le symptôme historique était « Successfully cached 0 workflows ».
|
||
|
||
**Solution.** Toujours extraire : `response?.entities || []`.
|
||
|
||
---
|
||
|
||
## D5 — Les propriétés arrivent en minuscules *ou* en majuscules
|
||
|
||
**Piège.** Selon le type d'élément et la version du WMS, l'API renvoie `name`
|
||
ou `Name`, `id` ou `Id`.
|
||
|
||
**Solution.** Systématiquement `const name = w.name || w.Name || ''` avant tout
|
||
filtrage ou tri. Une recherche qui « ne trouve pas » un élément qui existe est
|
||
presque toujours ce bug.
|
||
|
||
---
|
||
|
||
## D6 — dotenv doit écrire sur stderr
|
||
|
||
**Piège.** dotenv affiche une bannière de version sur **stdout**. Or le
|
||
protocole MCP réserve stdout au JSON : Claude Desktop échoue alors avec
|
||
`Unexpected token 'd', "[dotenv@17."... is not valid JSON`.
|
||
|
||
**Solution.** `src/index.js` détourne `process.stdout.write` vers stderr le
|
||
temps du chargement de dotenv, puis le restaure.
|
||
|
||
**Règle générale.** Dans tout le projet, on log avec `console.error()`.
|
||
**Jamais** `console.log()`.
|
||
|
||
---
|
||
|
||
## D7 — Le `.env` est lu à côté de l'exécutable quand le serveur est packagé
|
||
|
||
**Décision.** `src/index.js` résout le chemin du `.env` selon le contexte :
|
||
|
||
| Contexte | Chemin du `.env` |
|
||
|---|---|
|
||
| Sources (`npm start`) | racine du projet |
|
||
| Exécutable pkg (`process.pkg`) | dossier de `process.execPath` |
|
||
|
||
**Pourquoi.** Avec un chemin statique, pkg **embarque le `.env` dans le
|
||
snapshot** de l'exe : les credentials sont figés dans le binaire et
|
||
reconfigurer un déploiement impose un rebuild. Le chemin dynamique via
|
||
`process.execPath` empêche pkg de le détecter, donc rien n'est embarqué, et
|
||
`dist/.env` devient le fichier de configuration du déploiement.
|
||
|
||
**Vérification.** Sans `.env` à côté de l'exe, le serveur démarre en
|
||
avertissant `WMS_PROFILES is empty` — preuve qu'aucune valeur n'est embarquée.
|
||
|
||
---
|
||
|
||
## D8 — Multi-profils au runtime plutôt qu'un serveur MCP par WMS
|
||
|
||
**Décision.** Un seul serveur MCP dessert plusieurs backends WMS ; Claude bascule
|
||
avec `switch_wms_profile`.
|
||
|
||
**Pourquoi.** L'alternative — une entrée par client dans
|
||
`claude_desktop_config.json` — multiplie les processus, les jeux de credentials
|
||
et les caches, pour un usage où l'on ne consulte qu'un WMS à la fois.
|
||
|
||
**Conséquence.** L'état actif est **global au processus**. Un changement de
|
||
profil doit invalider tout ce qui dépend du tenant. Les services s'abonnent via
|
||
`profileManager.onSwitch()` :
|
||
|
||
| Service | Réaction au switch |
|
||
|---|---|
|
||
| `api-service` | `resetToken()` — le token OAuth appartient au tenant précédent |
|
||
| `workflow-service` | `clearCache()` |
|
||
| `ad-service` | `invalidateCache()` — tous les types |
|
||
|
||
**Ne jamais** appeler ces invalidations à la main depuis un autre module :
|
||
l'abonnement suffit, et le doublon masquerait un oubli d'abonnement.
|
||
|
||
**Sans profil actif** (`DEFAULT_WMS_PROFILE` absent ou invalide), `getCurrent()`
|
||
lève une erreur qui **énumère les profils disponibles**. C'est intentionnel :
|
||
Claude lit ce message et enchaîne sur `switch_wms_profile` au lieu d'échouer.
|
||
|
||
---
|
||
|
||
## D9 — Profils SaaS : accès aux logs refusé, pas silencieux
|
||
|
||
**Décision.** Quand `<PROFIL>_SAAS=true`, `read_recent_logs`, `search_logs` et
|
||
`list_log_files` **lèvent une erreur explicite** renvoyant vers les outils API.
|
||
|
||
**Pourquoi.** Le WMS est hébergé dans le cloud Mecalux : le partage
|
||
`\\<host>\inetpub\logs\...` n'est pas joignable. Retourner « 0 fichier » ferait
|
||
croire à une absence d'erreurs dans les logs, ce qui est un faux négatif
|
||
dangereux en diagnostic. Voir [docs/logs.md](docs/logs.md).
|
||
|
||
---
|
||
|
||
## D10 — Chargement paresseux + cache 1 h
|
||
|
||
**Décision.** Workflows et éléments AD ne sont **pas** chargés au démarrage,
|
||
mais à la première requête qui les concerne, puis mis en cache
|
||
(`WORKFLOW_CACHE_TTL`, 3 600 000 ms par défaut).
|
||
|
||
**Pourquoi.** L'ensemble représente ~38 800 éléments dont 29 374 `Resource` :
|
||
tout charger au boot ferait échouer le handshake MCP par timeout, pour des
|
||
données souvent inutiles à la session.
|
||
|
||
**Pagination.** La taille de page est réglée **par type** dans
|
||
`AD_ELEMENT_TYPES` (`src/services/ad-service.js`) : `View: 200`,
|
||
`Workflow: 5000`, `Resource: 15000`, tout le reste `100000` (soit une seule
|
||
page). Ces valeurs viennent de l'observation des timeouts serveur — les
|
||
augmenter à l'aveugle fait échouer les types lourds.
|
||
|
||
---
|
||
|
||
## D11 — Les paramètres système : `Parameter` + `ParamValue`, fusionnés en JS
|
||
|
||
**Piège.** Le modèle Reading ne contient **pas** d'entité
|
||
`CommandParameterData`. La configuration se lit dans deux entités :
|
||
|
||
| Entité | Contenu |
|
||
|---|---|
|
||
| `Parameter` | définition + `DefaultValue` |
|
||
| `ParamValue` | surcharges par entrepôt, liées par `ParameterId` |
|
||
|
||
**Décision.** `get_system_parameters` charge les deux intégralement (~200 et
|
||
~50 lignes) et fait la fusion **côté JavaScript**, en exposant la *valeur
|
||
effective* par entrepôt (surcharge si présente, défaut sinon).
|
||
|
||
**Pourquoi côté JS.** Les filtres (`warehouse`, `param_class`, `search`,
|
||
`only_overridden`) sont appliqués en JS pour éviter toute concaténation de
|
||
chaîne LINQ à partir d'entrées utilisateur — pas d'injection possible, et pas
|
||
de dépendance aux limites du traducteur LINQ (D12).
|
||
|
||
Fichier : `src/tools/config-tools.js`.
|
||
|
||
---
|
||
|
||
## D12 — Les dates relatives ne sont pas traduisibles en LINQ
|
||
|
||
**Piège vérifié en production.** `DateTime.Now`, `DateTime.Today` et
|
||
`AddDays()` ne sont **pas** traduits par le moteur de requêtes : la requête
|
||
échoue à la compilation.
|
||
|
||
**Solution.** Toujours une date littérale :
|
||
|
||
```csharp
|
||
Context.OutboundOrders.Where(z => z.CreationDate > new DateTime(2026, 8, 1))
|
||
```
|
||
|
||
C'est à l'appelant (donc à Claude) de calculer la date avant d'écrire la
|
||
requête.
|
||
|
||
---
|
||
|
||
## D13 — `select_expression` reste instable
|
||
|
||
**État.** Les projections passées via le paramètre API `Select` déclenchent des
|
||
erreurs de compilation côté serveur.
|
||
|
||
**Contournement actuel.** Interroger les lignes complètes et filtrer les
|
||
colonnes côté client.
|
||
|
||
**Non résolu.** C'est le principal point ouvert du projet. Toute tentative de
|
||
correction doit être validée sur un vrai WMS avant d'être documentée ici.
|
||
|
||
---
|
||
|
||
## D14 — `executeCommand` : pas de suffixe d'assembly
|
||
|
||
**Piège.** Ajouter `, Mecalux.ITSW.EasyWMS.Modules.Contracts` au nom de commande
|
||
provoque une `FileLoadException`.
|
||
|
||
**Solution.** Utiliser le `command_name` **tel quel** :
|
||
l'`InternalCommandName` fourni par l'AD contient déjà le nom pleinement
|
||
qualifié correct.
|
||
|
||
---
|
||
|
||
## D15 — Validation TLS désactivée
|
||
|
||
**Décision.** `httpsAgent: new https.Agent({ rejectUnauthorized: false })`.
|
||
|
||
**Pourquoi.** Les WMS on-premise sont exposés en HTTPS avec un certificat
|
||
auto-signé sur une IP privée.
|
||
|
||
**Limite assumée.** Acceptable sur réseau interne ou via VPN. Sur un profil
|
||
SaaS joint par Internet, cela supprime la protection contre l'interception —
|
||
à revoir si l'outil sort du cadre du diagnostic interne.
|
||
|
||
---
|
||
|
||
## D16 — Historique des shipment templates : hors périmètre
|
||
|
||
**Constat.** L'entité Reading `ShipmentTemplate` n'expose que la **dernière**
|
||
exécution (`LastExecuteDate`, `Status`, `IsEnabled`).
|
||
|
||
L'historique complet n'existe que dans les logs texte
|
||
`ApplyShipmentTemplates`, **absents de l'hôte joignable** (`10.255.255.2`) :
|
||
ils résident sur les serveurs de production / ETL des clients.
|
||
|
||
**Décision.** Aucun outil d'analyse de ces logs n'a été construit — il n'aurait
|
||
rien à lire. Les recettes purement API sont dans la resource
|
||
`wms://query-examples`.
|
||
|
||
---
|
||
|
||
## D17 — `WorkflowAction` et `WritingModel` retirés de la liste AD
|
||
|
||
**Constat.** Les endpoints `/AD/api/WorkflowAction/GetByApplication` et
|
||
`/AD/api/WritingModel/GetByApplication` répondent `404 Not Found`.
|
||
|
||
**Décision.** Ces deux types sont sortis de `AD_ELEMENT_TYPES` : il en reste
|
||
**20**, dont 3 valides mais vides (`Dashboard`, `TimelineTemplate`, `Toggle`).
|
||
Détail de la campagne de validation :
|
||
[docs/ad-api-validation.md](docs/ad-api-validation.md).
|
||
|
||
---
|
||
|
||
## D18 — Build : `@yao-pkg/pkg` ciblant node22
|
||
|
||
**Décision.** Le build utilise `@yao-pkg/pkg` (fork maintenu de `pkg`, archivé
|
||
depuis) avec la cible **`node22-win-x64`**.
|
||
|
||
**Pourquoi cette cible.** `node20-win-x64` n'a pas de binaire prébuilt
|
||
disponible : pkg bascule alors sur une compilation de Node depuis les sources,
|
||
qui échoue faute de `vcbuild.bat` (toolchain MSVC absente).
|
||
|
||
**Avertissements normaux au build.** `Cannot find module
|
||
'@modelcontextprotocol/sdk/server/index.js'` et `Entry 'main' not found` :
|
||
pkg ne sait pas résoudre statiquement la table `exports` du SDK. L'exécutable
|
||
produit **fonctionne** — vérifié en démarrant l'exe. Ne pas chercher à
|
||
« corriger » ces avertissements.
|
||
|
||
---
|
||
|
||
## D19 — La resource `docs://` a été supprimée
|
||
|
||
**Constat.** `src/resources/documentation.js` (193 lignes) exposait un index des
|
||
`.md` de `docs/`, mais n'a **jamais été branché** dans `src/index.js` : le
|
||
handler `resources/list` ne l'incluait pas et `resources/read` ne routait aucune
|
||
URI `docs://`. `docs/README.md` promettait pourtant la fonctionnalité aux
|
||
utilisateurs.
|
||
|
||
**Décision.** Fichier supprimé, `docs/README.md` corrigé. `docs/` reste un
|
||
dossier de référence pour les humains et pour un agent qui lit le dépôt — pas
|
||
une resource MCP.
|
||
|
||
**Si on veut la fonctionnalité un jour**, il faut la brancher réellement (2
|
||
lignes dans `src/index.js`) *et* décider de son sort dans l'exécutable pkg, qui
|
||
n'embarque pas `docs/`.
|
||
|
||
---
|
||
|
||
## D20 — Purge du dépôt (2026-08-24)
|
||
|
||
Supprimés lors du nettoyage :
|
||
|
||
| Élément | Raison |
|
||
|---|---|
|
||
| 5 `.md` dupliqués à la racine | copies md5-identiques de `docs/api/` et `docs/entities/` |
|
||
| `JANITOR_main.js`, `JANITOR_entities.json` | application Electron sans lien avec le MCP |
|
||
| `temp/*.json` | dumps de workflows versionnés par accident (`temp/` désormais ignoré) |
|
||
| `claude_desktop_config_ssh.json` | **mots de passe en clair** + variables `ORACLE_*` de l'architecture supprimée (D1) |
|
||
| `IMPLEMENTATION_SUMMARY.md` | doublon d'`AD_API_TEST_RESULTS.md`, déplacé en `docs/ad-api-validation.md` |
|
||
| `src/resources/documentation.js` | code mort (D19) |
|
||
| `src/config/constants.js` (83 l.) | module entier inutilisé : `require` présent dans `wms-query-service.js`, mais **aucune** de ses constantes n'était lue |
|
||
| `log-service.js` : `findRecentErrors`, `readFullLog`, `getLogStats` (~100 l.) | exportées, jamais appelées — aucun outil MCP ne les exposait |
|
||
| `RESOURCE_URIS.WORKFLOWS_CATEGORIES` | URI déclarée, jamais servie |
|
||
| `LOG_FILE_PATTERN` | lue depuis `.env`, jamais utilisée (le scan filtre sur `.log` en dur) |
|
||
|
||
Les trois fonctions de `log-service.js` étaient fonctionnelles ; si l'une d'elles
|
||
redevient utile (`findRecentErrors` en particulier), la reprendre depuis le
|
||
commit `b59cbb3` et **l'exposer réellement** comme outil MCP plutôt que de la
|
||
laisser inatteignable.
|
||
|
||
⚠️ **Credentials à faire tourner.** `claude_desktop_config_ssh.json` et
|
||
l'ancienne version de `test-ad-api.ps1` contenaient des mots de passe en clair.
|
||
Le fichier est retiré du répertoire de travail, **mais il reste dans
|
||
l'historique git** (commit `b59cbb3`). Considérez ces mots de passe comme
|
||
compromis et changez-les ; à défaut, réécrivez l'historique avant toute
|
||
publication du dépôt.
|
||
|
||
---
|
||
|
||
## D21 — `Context.{...}` attend le `TableName` du Metadata, résolu par service
|
||
|
||
**Piège.** Les expressions LINQ de `QueryExecute` référencent les entités par
|
||
le `TableName` de l'API Metadata, **pas** par le nom d'entité de l'Application
|
||
Dictionary. Ce n'est pas une pluralisation : `Container` -> `Containers`, mais
|
||
`Alias` -> `Alias` (invariant), et `Item` n'existe pas. Un nom faux part en
|
||
HTTP 500 (erreur de compilation `'ApplicationReadingContext' ne contient pas
|
||
de définition pour '...'`). Seul `TableName` fait foi — **ne réinventez pas de
|
||
règle grammaticale**.
|
||
|
||
**Décision.** `src/services/entity-resolver.js` construit une table
|
||
`Name | TableName (insensible à la casse) -> TableName` et tous les points
|
||
d'interpolation (`wms-query-service`, `call_query_api`) passent par elle.
|
||
Mesures du 24/08/2026 (`LIMAGRAI2512`) :
|
||
|
||
- Le contexte de lecture est **commun au tenant** : la table agrège le
|
||
Metadata de toutes les applications. La liste vient de
|
||
`GET /configuration/applications` (5 applications déployées avec version) —
|
||
les applications EasyBuilder sans contexte requêtable (`CustomApp`…) n'y
|
||
figurent pas et ne fournissent de toute façon **0 entité** Metadata.
|
||
- 288 `TableName` distincts, aucun conflit `Name -> TableName` entre
|
||
applications.
|
||
|
||
Comportements :
|
||
|
||
- **Nom inconnu** : échec avant tout appel réseau de requête, message avec
|
||
suggestions proches et renvoi vers `get_entity_metadata`.
|
||
- **Metadata injoignable** : le nom passe tel quel (comportement historique)
|
||
et la réponse porte un `warning` — on ne bloque pas tout le serveur pour un
|
||
cache irrécupérable.
|
||
- Cache : TTL partagé (`WORKFLOW_CACHE_TTL`), chargement paresseux,
|
||
invalidation par abonnement `onSwitch()` (D8, D10).
|
||
|
||
---
|
||
|
||
## D22 — Routage des outils par table explicite, plus par préfixe de nom
|
||
|
||
**Piège.** Le handler `tools/call` de `src/index.js` routait par préfixe de nom
|
||
(`startsWith`, `includes`) dans une cascade de `else if`. Deux outils listés
|
||
dans `tools/list` n'atteignaient jamais leur module — reproduits le
|
||
24/08/2026 :
|
||
|
||
| Outil | Cause | Erreur renvoyée |
|
||
|---|---|---|
|
||
| `get_entity_metadata` | capté par `startsWith('get_entity_')` (branche `wms-query-tools`, placée avant la sienne) | `Unknown WMS query tool: get_entity_metadata` |
|
||
| `list_log_files` | la branche logs testait `includes('_logs')`, or le nom contient `_log_files` | `Unknown tool: list_log_files` |
|
||
|
||
Le routage par préfixe fait dépendre la joignabilité d'un outil de l'**ordre
|
||
des branches** et de conventions de nommage implicites : chaque ajout d'outil
|
||
pouvait en casser un autre silencieusement.
|
||
|
||
**Décision.** Une table `nom d'outil → module` est construite au démarrage en
|
||
parcourant les `listTools()` des 8 modules de `src/tools/`. `tools/list` est
|
||
servi depuis cette même table et le dispatch est un lookup : un outil listé
|
||
est un outil routé, **par construction**. Deux modules déclarant le même nom
|
||
font échouer le serveur au démarrage (message nommant les deux modules) —
|
||
c'est un bug de développement, pas un cas d'exécution.
|
||
|
||
La table ne présume rien de la signature des outils : `(name, args)` est
|
||
transmis tel quel au `executeTool()` du module. Ajouter un paramètre à un
|
||
outil ne la concerne pas.
|
||
|
||
---
|
||
|
||
## D23 — Le SDK ne valide pas les arguments : validation dans le wrapper
|
||
|
||
**Piège mesuré (24/08/2026).** Le SDK MCP (`@modelcontextprotocol/sdk` 1.x)
|
||
ne valide **pas** les arguments d'appel contre l'`inputSchema` déclaré :
|
||
`additionalProperties: false` est ignoré, et un paramètre inconnu
|
||
(`read_recent_logs(lines: 60)`) retombe silencieusement sur les défauts
|
||
(`count = 100`) sans le moindre signal.
|
||
|
||
**Décision.** Le wrapper `tools/call` de `src/index.js` valide chaque appel
|
||
contre le schéma de la table de routage (D22) avant le dispatch — schéma
|
||
déclaré = contrat appliqué, pour les 23 outils d'un coup :
|
||
|
||
- **paramètre inconnu** → erreur structurée nommant le paramètre fautif **et**
|
||
les paramètres valides de l'outil ;
|
||
- **paramètre `required` manquant** → même forme d'erreur.
|
||
|
||
Les 23 schémas portent aussi `additionalProperties: false` : inerte côté SDK,
|
||
mais c'est le contrat que lisent les clients. La validation reste volontairement
|
||
superficielle (noms et présence, pas les types) : le but est de supprimer le
|
||
silence, pas de réimplémenter JSON Schema.
|
||
|
||
Le renommage des paramètres (`entity_type`/`query` uniformisés) a été **écarté**
|
||
au profit de cette validation — voir ROADMAP « Écarté ».
|
||
|
||
---
|
||
|
||
## D24 — Contrat de troncature : borné + signalé, jamais un rejet silencieux
|
||
|
||
**Piège mesuré (24-25/08/2026).** Une réponse d'outil de ~70 000 caractères
|
||
(`get_system_parameters` sans filtre ; `search_logs` atteignait 52-56 000 avec
|
||
les seuls défauts) est **rejetée par le client MCP** — l'utilisateur voit un
|
||
échec opaque au lieu d'un résultat partiel.
|
||
|
||
**Décision.** Tout outil susceptible de produire une sortie volumineuse borne
|
||
sa réponse et **signale** la coupe. Le signal est commun :
|
||
|
||
| Champ | Sémantique |
|
||
|---|---|
|
||
| `truncated: true` | présent **uniquement** quand la réponse a été coupée — jamais `truncated: false` |
|
||
| `hint` | présent ssi `truncated` ; actionnable : dit comment continuer (`offset` suivant) ou réduire (filtres, `context_lines`…) |
|
||
| `returned` | nombre d'éléments effectivement renvoyés |
|
||
| total (`totalParameters`, `totalResults`, `dataTotalChars`) | total **avant** la coupe — `truncated` se vérifie donc depuis la réponse elle-même |
|
||
|
||
Les mécanismes restent **volontairement locaux**, car ils diffèrent :
|
||
`get_system_parameters` pagine (`limit`/`offset` au schéma — rien n'est perdu,
|
||
on continue avec l'offset suivant) ; `search_logs` plafonne le volume
|
||
(`MAX_LOG_SEARCH_CHARS`, défaut 25 000 caractères) en écartant des résultats
|
||
**entiers** — jamais coupés au milieu de leurs lignes de contexte — et annonce
|
||
en plus `omitted`, le compte écarté. Ces deux-là ne partagent pas de helper : le
|
||
factoriser forcerait une abstraction commune à deux mécanismes qui n'en ont pas.
|
||
Les **trois outils de requête**, eux, partagent le même mécanisme — ils
|
||
partagent donc `src/services/response-limit.js` (voir ci-dessous). Le critère
|
||
est le mécanisme, pas le nombre d'appelants.
|
||
|
||
**Périmètre étendu (lot 5, 25/08/2026).** Trois familles d'outils dépassaient
|
||
encore le seuil, toutes mesurées sur `LIMAGRAI2512` :
|
||
|
||
`get_workflow_details` **fenêtre le blob `data`** (`max_data_chars`, défaut
|
||
20 000 ; `data_offset`, défaut 0) — 79 092 caractères pour un StackerCrane
|
||
(dont 71 512 de blob), 101 816 pour `CST_SendRejectContainersToPK` (92 362 de
|
||
blob), ramenés à ~23 000. La tranche est **verbatim** : découpe de chaîne, rien
|
||
d'autre. Ne jamais résumer, reformuler ni « parser » cette définition
|
||
EasyBuilder — la concaténation des tranches dans l'ordre des offsets doit la
|
||
reconstituer à l'octet près (vérifié : 20 000 + 20 000 + 20 000 + 11 512 =
|
||
71 512, concaténation identique au blob d'origine). Les métadonnées du workflow
|
||
restent complètes dans chaque tranche ; seul `data` est fenêtré, et
|
||
`dataTotalChars` est porté par **toute** réponse — y compris non tronquée, où
|
||
la seule différence avec l'ancienne réponse est ces trois champs de fenêtre
|
||
(+65 caractères mesurés).
|
||
|
||
`query_wms_entities`, `call_query_api` et `search_wms_data` **plafonnent leur
|
||
volume** (`MAX_QUERY_RESPONSE_CHARS`, défaut 25 000 — même ordre de grandeur que
|
||
`MAX_LOG_SEARCH_CHARS`) en écartant des **lignes entières**, via le helper
|
||
commun `src/services/response-limit.js` (recherche dichotomique : ~8
|
||
constructions au lieu de 200 retraits ligne à ligne sur des charges utiles de
|
||
~1 Mo) :
|
||
|
||
| Appel | Avant | Après |
|
||
|---|---:|---:|
|
||
| `query_wms_entities("Products", limit: 200)` | 957 234 | 24 432 (5 lignes sur 200) |
|
||
| `search_wms_data("PAL")` | 847 543 | 22 992 (4 résultats sur 150) |
|
||
| `call_query_api("Products", query_type: 1, limit: 1)` | 95 288 | 738 |
|
||
|
||
Trois points de cadrage, tous vérifiés en exécution :
|
||
|
||
- **Sous le plafond, rien ne change.** Aucun champ ajouté, réponse identique
|
||
**octet pour octet** (mesuré sur `query_wms_entities("Container", limit: 1)`,
|
||
`call_query_api`, `get_entity_schema`, `search_wms_data` sous plafond).
|
||
`MAX_QUERY_ROWS` et les limites par défaut des outils sont inchangés :
|
||
le correctif est le bornage signalé, pas une réduction silencieuse.
|
||
- **Cas limite : une seule ligne dépasse le plafond.** Réel en Writing —
|
||
`call_query_api("Products", query_type: 1, limit: 1)` répond `returned: 0`,
|
||
`omitted: 1`, `truncated: true`, avec un hint qui explique le volume Writing
|
||
et renvoie vers Reading. C'est moins bon qu'un résultat, mais c'est mieux
|
||
qu'un rejet client opaque.
|
||
- **`search_wms_data` répartit en tourniquet** les résultats gardés entre les
|
||
entités, et porte `returned`/`omitted` par entité en plus des totaux. Sans
|
||
cela, une entité volumineuse placée en tête consommerait tout le budget et
|
||
les suivantes reviendraient à zéro résultat sans que rien ne le dise —
|
||
exactement le faux négatif que corrige L5.4.
|
||
|
||
`count_wms_entities` n'est pas concerné (`QueryScalarExecute` renvoie un
|
||
scalaire), et son `query_type` ne porte donc pas l'avertissement de volume
|
||
ajouté aux deux autres.
|
||
|
||
Deux garde-fous de cadrage :
|
||
|
||
- **Ne pas réduire les défauts existants** (`max_results` 50, `context_lines` 2)
|
||
pour passer sous le plafond : le correctif est le bornage signalé, pas un
|
||
changement silencieux de comportement.
|
||
- La taille qui fait foi est celle de `content[0].text` **mesurée via le
|
||
protocole**, pas une estimation. Ordre de grandeur cible : ~20-25 000
|
||
caractères par réponse.
|
||
|
||
Au passage, `totalParameters` a changé de sens : c'était le nombre brut
|
||
d'entités `Parameter` chargées, c'est désormais le total correspondant aux
|
||
filtres avant pagination (identique sans filtre).
|
||
|
||
---
|
||
|
||
## D25 — `query_type` : opt-in explicite, D3 reste la règle par défaut
|
||
|
||
**Contexte (mesures des 24-25/08/2026, `LIMAGRAI2512`).** `QueryType` était
|
||
figé à `0` en dur dans `executeQuery()` et `executeScalarQuery()`, rendant le
|
||
modèle Writing — opérationnel et mesuré (`Context.Products` en `QueryType: 1`
|
||
répond) — inatteignable.
|
||
|
||
**Décision.** Un paramètre `query_type` (entier, défaut `0`) est exposé sur
|
||
**trois outils** : `call_query_api`, `query_wms_entities`,
|
||
`count_wms_entities`. `get_entity_schema` et `search_wms_data` restent des
|
||
raccourcis Reading, sans paramètre.
|
||
|
||
**Rapport à D3.** D3 n'est pas révisée : le Reading reste la règle par défaut,
|
||
car en Writing les statuts sont des **énumérations** — les comparaisons de
|
||
chaînes (`== "Release"`), cas le plus courant en debug, y échouent. La bascule
|
||
est un opt-in explicite et les descriptions d'outils portent l'avertissement.
|
||
|
||
Modalités :
|
||
|
||
- **Garde de valeur dans le code de l'outil**, pas dans le wrapper : D23 valide
|
||
les noms de paramètres, pas les valeurs. Hors `0..3` (ou non entier) →
|
||
erreur locale via `assertValidQueryType()` (`wms-query-service.js`), **avant
|
||
tout appel réseau**, nommant les quatre contextes.
|
||
- **`2` et `3` sont transmis tels quels** : le WMS répond et son diagnostic
|
||
remonte entier (L1.1). Sur `LIMAGRAI2512` : `2` = DataWarehouse non configuré
|
||
(`Could not resolve serviceType 'IDataWarehouse…'`), `3` = Metrics, contexte
|
||
présent mais modèle distinct (`'ApplicationMetricDataContext' ne contient pas
|
||
de définition pour 'Products'`).
|
||
- **Interaction avec le resolver (D21)** : la table de résolution est
|
||
construite sur le Metadata **Reading**. Quand `query_type != 0`, un nom qui
|
||
se résout se résout normalement (`Products` marche en Writing, mesuré) ; un
|
||
nom **inconnu** du Reading n'est **pas** bloqué — il passe tel quel avec un
|
||
`warning` dans la réponse (`allowUnknown` du resolver, même mécanique que le
|
||
repli « Metadata injoignable »), car le modèle Writing/Metrics peut contenir
|
||
des entités hors Reading. Le `warning` est conservé aussi dans la réponse
|
||
d'erreur si le WMS échoue ensuite.
|
||
|
||
---
|
||
|
||
## D26 — Paramètre `application` : caches par application, chargement toujours paresseux
|
||
|
||
**Contexte (mesures des 24-25/08/2026, `LIMAGRAI2512`).** L'application
|
||
interrogée venait de `WMS_APPLICATION` (partagée par tous les profils) : le MCP
|
||
ne voyait que `EasyWMS`. Or `POST /AD/api/Application/GetAll` déclare **9
|
||
applications**, et **CustomApp porte le spécifique client** (153 workflows
|
||
`CST_*` sur ce tenant) — précisément ce qu'on cherche en debug. Les 11 entités
|
||
`CustomApp` ne sont requêtables dans aucun contexte : l'API AD est le seul
|
||
accès au spécifique client.
|
||
|
||
**Décision.** Un paramètre `application` (défaut : l'application du profil,
|
||
donc comportement strictement inchangé sans lui) sur six outils :
|
||
`get_ad_elements`, `search_ad_elements`, `get_ad_element_details`,
|
||
`search_workflows`, `get_workflow_details`, `list_workflow_categories`.
|
||
|
||
**Contrat de cache.**
|
||
|
||
| Service | Clé avant | Clé après |
|
||
|---|---|---|
|
||
| `ad-service` | un cache par type | un cache par **(application, type)** (`app::type`) |
|
||
| `workflow-service` | un cache global | un cache par **application** |
|
||
|
||
Sans ces clés, un appel CustomApp polluerait le cache EasyWMS du même type.
|
||
Règles associées :
|
||
|
||
- **L'invalidation reste l'abonnement `onSwitch()`** (D8) : la bascule de
|
||
profil vide **tous** les caches, toutes applications confondues. Aucune
|
||
invalidation manuelle inter-module.
|
||
- **Pas de préchargement des 9 applications** (D10) : seule l'application
|
||
effectivement demandée est chargée — le type `Resource` pèse 29 374 éléments
|
||
sur la seule EasyWMS.
|
||
- `workflow-service` cache aussi la liste de `Application/GetAll`, **allégée**
|
||
(`name`, `id`, `version`) : chaque élément de la réponse brute embarque un
|
||
blob `data` de ~100 Ko (la définition EasyBuilder complète) qu'on ne
|
||
conserve pas.
|
||
- `list_workflow_categories` est adossé à `Application/GetAll` (les 9
|
||
applications) et non plus aux `applicationName` du seul cache actif. La note
|
||
de L1.3 reste vraie — pas de champ catégorie ; les comptes de workflows ne
|
||
sont affichés que pour les applications déjà chargées (paresseux). Le
|
||
paramètre `category` de `search_workflows` (filtre sur `applicationName`)
|
||
subsiste : `application` choisit le jeu chargé, `category` filtre dedans —
|
||
leur articulation est documentée dans les descriptions.
|
||
- `get_application_summary` regroupe l'état par application puis par type et
|
||
ne détaille que les entrées **effectivement en cache** : la sortie reste
|
||
bornée quel que soit le nombre d'applications interrogées (D24). Il expose
|
||
aussi les caches de workflows par application.
|
||
|
||
**Le paramètre ne suffisait pas : il faut que la réponse le dise** (lot 5,
|
||
25/08/2026). Cas réel : une session Cowork cherchant des workflows `CST_*` sans
|
||
passer `application: "CustomApp"` a conclu que l'AD n'en contenait aucun — alors
|
||
que `CST_PickingTasksSequencing_PR` et `CST_ChooseDestinationFromPS` existent.
|
||
Le paramètre était disponible et documenté ; ce qui manquait, c'est que **rien
|
||
dans la réponse ne disait qu'on n'avait regardé qu'une application sur neuf**.
|
||
Un défaut silencieux se lit comme une exhaustivité.
|
||
|
||
`search_workflows` et `search_ad_elements` rappellent donc **toujours**
|
||
l'application effectivement interrogée (plus seulement quand le paramètre a été
|
||
passé), et ajoutent un `hint` quand la recherche revient **vide** :
|
||
|
||
- Seuil à **0 résultat**, pas « peu ». Toute valeur non nulle produirait un hint
|
||
parasite sur une recherche légitimement étroite, et le mode d'échec observé
|
||
est bien le zéro pris pour une absence.
|
||
- Le hint nomme les autres applications depuis la liste allégée **déjà en
|
||
cache** ; sans elle, il reste générique et renvoie vers
|
||
`list_workflow_categories`. **Jamais de fetch pour construire un hint** —
|
||
ce serait précisément le préchargement que cette décision interdit.
|
||
- Il nomme `CustomApp` en clair, sauf quand c'est déjà l'application
|
||
interrogée : c'est une connaissance statique, déjà portée par les
|
||
descriptions d'outils, pas une donnée à aller chercher.
|
||
|
||
---
|
||
|
||
## D27 — Chargements paresseux sous concurrence : single-flight par clé
|
||
|
||
**Contexte (mesuré le 25/08/2026, `LIMAGRAI2512`).** Le serveur traite les
|
||
`tools/call` **en concurrence** : une rafale d'appels dans une même session
|
||
s'exécute en parallèle. Les trois services à cache chargeaient paresseusement
|
||
sans se coordonner — le premier appelant qui trouve le cache invalide lance le
|
||
fetch, et tous ceux qui arrivent pendant ce fetch trouvent le cache **encore**
|
||
invalide et lancent le leur. Mesures avant correction :
|
||
|
||
| Rafale | Résultat |
|
||
|---|---|
|
||
| 6 × `search_workflows` (CustomApp) | 6 × `fetching from API` pour une seule clé |
|
||
| 6 × `query_wms_entities` (Container) | 6 × `[EntityResolver] Cache expired or empty` — soit 30 GET Metadata |
|
||
| 14 appels mixtes | l'API AD répond **HTTP 500** sur `Workflow/GetByApplication` (EasyWMS, ~4 000 workflows) — les 4 appels EasyWMS échouent, les mêmes passent en séquentiel |
|
||
|
||
La dernière ligne est le vrai coût : la duplication ne gaspille pas seulement
|
||
des appels, elle **surcharge l'API AD au point de la faire échouer**.
|
||
|
||
**Décision.** Un motif unique, `src/services/single-flight.js`, partagé par
|
||
`workflow-service`, `ad-service` et `entity-resolver` — une `Map` de promesses,
|
||
pas une dépendance externe :
|
||
|
||
- **Une clé de single-flight par entrée de cache** : `workflows::<app>` et
|
||
`applications` pour les workflows, `<app>::<type>` pour l'AD, une clé unique
|
||
pour le resolver. Deux clés distinctes se chargent toujours **en parallèle** —
|
||
le single-flight ne sérialise rien au-delà de la clé demandée, et
|
||
n'introduit aucun préchargement (D26 intact).
|
||
- **La promesse est retirée au règlement, succès *ou* échec.** Un fetch en
|
||
erreur ne reste pas coincé dans la Map : l'appel suivant refetche. Les
|
||
appelants joints reçoivent la même erreur, et rien n'est mis en cache.
|
||
- **Le log de fetch reste l'observable** (D6) : une ligne `fetching from API`
|
||
/ `Cache expired or empty` par chargement **réel**. Les appelants joints
|
||
émettent une ligne distincte (`Fetch already in flight … joining it`) — ne
|
||
fusionnez pas les deux, c'est ce qui rend la déduplication vérifiable depuis
|
||
stderr.
|
||
|
||
**Mesures après.** Rafale de 6 (CustomApp) → 1 fetch + 5 joins, les 6 réponses
|
||
à `count: 44`. Rafale de 6 (resolver) → 1 chargement, 5 GET Metadata au lieu de
|
||
30. Rafale mixte EasyWMS + CustomApp → **un fetch par application**, deux au
|
||
total, plus aucun HTTP 500. Le chemin séquentiel nominal est inchangé : 1 fetch
|
||
puis 1 `Using cached data`, 0 join.
|
||
|
||
**Ce que cette décision ne couvre pas.** La bascule de profil concurrente aux
|
||
appels en vol (un `switch_wms_profile` qui redirige des requêtes déjà parties)
|
||
reste un point ouvert de la ROADMAP, distinct.
|