Files
mcp-wms-api/DECISIONS.md
T
Arthur Ria 706e628715 L4.1 : expose query_type sur les outils de requête (D25)
QueryType était figé à 0 en dur dans executeQuery/executeScalarQuery : le
modèle Writing, opérationnel et mesuré, était inatteignable. Paramètre
query_type (défaut 0) sur call_query_api, query_wms_entities,
count_wms_entities — get_entity_schema et search_wms_data restent des
raccourcis Reading.

D3 reste la règle par défaut : la bascule est un opt-in, avertie dans les
descriptions d'outils (statuts en énumérations en Writing). Garde de valeur
assertValidQueryType dans le code (le wrapper D23 ne valide pas les valeurs),
avant tout réseau. En query_type != 0, un nom inconnu du Metadata Reading
passe tel quel avec warning (allowUnknown du resolver) — conservé aussi dans
la réponse d'erreur si le WMS échoue ensuite. Acté en D25 ; CLAUDE.md nuancé,
L4.1 retiré de la ROADMAP (reliquat : exploration Metrics, rapport à part).

Vérifications rejouées via le protocole (LIMAGRAIN / LIMAGRAI2512) :
- call_query_api(Products, query_type:1, limit:1) -> success, 1 ligne,
  queryType:1 dans la réponse.
- call_query_api(Products, query_type:3) -> erreur structurée contenant
  'ApplicationMetricDataContext' ne contient pas de définition pour 'Products'.
- call_query_api(Products, query_type:7) -> erreur locale nommant les 4
  contextes, aucun [API] POST/GET dans stderr.
- query_wms_entities(Container, limit:1) sans query_type -> comportement
  inchangé (resolvedTableName Containers, Reading, pas de champ queryType).
- call_query_api(FooBar123, query_type:1) -> transmis tel quel (payload
  QueryType:1 Expression Context.FooBar123...), erreur WMS
  ApplicationWritingRepository + warning de résolution dans la réponse.
- count_wms_entities(Product, query_type:1) -> count 51145 (~51160).
Baseline : tools/list 23, resources 6, rejet D23 d'un paramètre inconnu OK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 11:17:10 +02:00

23 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 ReadingApplicationReadingContext 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 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) 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é. Pas de helper partagé : le factoriser forcerait une abstraction commune à deux mécanismes qui n'en ont pas.

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.