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>
33 KiB
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.
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 :
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.
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
TableNamedistincts, aucun conflitName -> TableNameentre 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 abonnementonSwitch()(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
requiredmanquant → 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_datasous plafond).MAX_QUERY_ROWSet 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épondreturned: 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_datarépartit en tourniquet les résultats gardés entre les entités, et portereturned/omittedpar 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_results50,context_lines2) 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].textmesuré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 viaassertValidQueryType()(wms-query-service.js), avant tout appel réseau, nommant les quatre contextes. 2et3sont transmis tels quels : le WMS répond et son diagnostic remonte entier (L1.1). SurLIMAGRAI2512: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 (Productsmarche en Writing, mesuré) ; un nom inconnu du Reading n'est pas bloqué — il passe tel quel avec unwarningdans la réponse (allowUnknowndu resolver, même mécanique que le repli « Metadata injoignable »), car le modèle Writing/Metrics peut contenir des entités hors Reading. Lewarningest 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
Resourcepèse 29 374 éléments sur la seule EasyWMS. workflow-servicecache aussi la liste deApplication/GetAll, allégée (name,id,version) : chaque élément de la réponse brute embarque un blobdatade ~100 Ko (la définition EasyBuilder complète) qu'on ne conserve pas.list_workflow_categoriesest adossé àApplication/GetAll(les 9 applications) et non plus auxapplicationNamedu 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ètrecategorydesearch_workflows(filtre surapplicationName) subsiste :applicationchoisit le jeu chargé,categoryfiltre dedans — leur articulation est documentée dans les descriptions.get_application_summaryregroupe 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
CustomAppen 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>etapplicationspour 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 emptypar 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.