Compare commits
7 Commits
8c5792da52
...
7b25e79e98
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b25e79e98 | |||
| fdebca500f | |||
| 37c68a4d9a | |||
| 808586e615 | |||
| 97ab56f928 | |||
| 5386f54922 | |||
| cb625a7918 |
@@ -60,6 +60,7 @@ src/
|
|||||||
│ └── logs.js logs://guide — patterns d'erreur et scénarios de debug
|
│ └── logs.js logs://guide — patterns d'erreur et scénarios de debug
|
||||||
├── services/ Logique métier
|
├── services/ Logique métier
|
||||||
│ ├── api-service.js OAuth + client HTTP + helpers de requête (singleton)
|
│ ├── api-service.js OAuth + client HTTP + helpers de requête (singleton)
|
||||||
|
│ ├── entity-resolver.js Résolution Name|TableName -> TableName (D21)
|
||||||
│ ├── workflow-service.js Workflows, lazy loading + cache
|
│ ├── workflow-service.js Workflows, lazy loading + cache
|
||||||
│ ├── ad-service.js Application Dictionary, 20 types, cache par type
|
│ ├── ad-service.js Application Dictionary, 20 types, cache par type
|
||||||
│ ├── wms-query-service.js Construction d'expressions LINQ
|
│ ├── wms-query-service.js Construction d'expressions LINQ
|
||||||
@@ -219,11 +220,14 @@ Autres règles :
|
|||||||
|
|
||||||
## Entités et éléments AD
|
## Entités et éléments AD
|
||||||
|
|
||||||
**Entités interrogeables** (Query API) : `Products`, `Containers`, `Accounts`,
|
**Entités interrogeables** (Query API) : `entity_type` accepte le nom d'entité
|
||||||
`Suppliers`, `Kits`, `Aliases`, `Tasks`, `Stocks`, `ProductLocations`,
|
AD (`Container`) ou le `TableName` (`Containers`), insensible à la casse — la
|
||||||
`InboundOrders`, `Receptions`, `OutboundOrders`. La liste faisant foi s'obtient
|
résolution passe par `entity-resolver.js` (D21). Courantes : `Products`,
|
||||||
par `get_entity_metadata` (API Metadata) — le catalogue de la resource
|
`Containers`, `Accounts`, `Suppliers`, `Kits`, `Alias` (invariant, pas de
|
||||||
`wms://entities` est un raccourci de confort, pas la référence.
|
pluriel), `Tasks`, `Stocks`, `ProductLocations`, `InboundOrders`, `Receptions`,
|
||||||
|
`OutboundOrders`. La liste faisant foi (288 entités, toutes applications
|
||||||
|
confondues) s'obtient par `get_entity_metadata` (API Metadata) — le catalogue
|
||||||
|
de la resource `wms://entities` est un raccourci de confort, pas la référence.
|
||||||
|
|
||||||
**Application Dictionary** : 20 types, ~38 800 éléments. `Resource` (29 374) est
|
**Application Dictionary** : 20 types, ~38 800 éléments. `Resource` (29 374) est
|
||||||
de loin le plus lourd ; 3 types sont valides mais vides (`Dashboard`,
|
de loin le plus lourd ; 3 types sont valides mais vides (`Dashboard`,
|
||||||
@@ -278,14 +282,5 @@ printf '%s\n%s\n%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"
|
|||||||
|
|
||||||
## Points ouverts
|
## Points ouverts
|
||||||
|
|
||||||
Voir [ROADMAP.md](ROADMAP.md) : lots de correction planifiés, cause racine
|
Voir [ROADMAP.md](ROADMAP.md) : lots de correction planifiés et propositions
|
||||||
commune (résolution `Name` -> `TableName` des entités), et propositions
|
|
||||||
explicitement écartées.
|
explicitement écartées.
|
||||||
|
|
||||||
⚠️ Piège connu et non encore corrigé, à garder en tête en attendant le lot 2 :
|
|
||||||
|
|
||||||
- `entity_type` est interpolé sans validation dans `Context.{entity_type}`. Le
|
|
||||||
nom attendu est le `TableName` de l'API Metadata, pas le nom d'entité de l'AD
|
|
||||||
(`Container` -> `Containers`, mais `Alias` -> `Alias`). Un mauvais nom donne un
|
|
||||||
HTTP 500 — dont le détail (erreur de compilation LINQ) remonte désormais dans
|
|
||||||
la réponse de l'outil (L1.1).
|
|
||||||
|
|||||||
@@ -353,6 +353,41 @@ 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
|
## 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
|
**Piège.** Le handler `tools/call` de `src/index.js` routait par préfixe de nom
|
||||||
@@ -379,3 +414,29 @@ 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
|
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
|
transmis tel quel au `executeTool()` du module. Ajouter un paramètre à un
|
||||||
outil ne la concerne pas.
|
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é ».
|
||||||
|
|||||||
+19
-104
@@ -12,84 +12,11 @@ contient que ce qui reste à faire.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Cause racine commune
|
|
||||||
|
|
||||||
Le MCP interpole `entity_type` dans `Context.{entity_type}` **sans aucune
|
|
||||||
validation** (vérifié : aucune liste blanche dans le code). Or le nom attendu
|
|
||||||
par le contexte de lecture n'est pas le nom d'entité de l'Application
|
|
||||||
Dictionary.
|
|
||||||
|
|
||||||
L'API Metadata (`GET /Metadata/Entities`, **232 entités**) donne la
|
|
||||||
correspondance exacte :
|
|
||||||
|
|
||||||
| `Name` (renvoyé par `search_ad_elements`) | `TableName` (attendu par `Context.`) |
|
|
||||||
|---|---|
|
|
||||||
| `Container` | `Containers` |
|
|
||||||
| `Product` | `Products` |
|
|
||||||
| `ContainerType` | `ContainerTypes` |
|
|
||||||
| `Alias` | `Alias` — **invariant, pas de pluriel** |
|
|
||||||
| `Item` | *n'existe pas dans le modèle Reading* |
|
|
||||||
|
|
||||||
Ce n'est donc pas une règle de pluralisation : c'est un mapping, et seul
|
|
||||||
`TableName` fait foi. `TableName` est unique sur les 232 entités.
|
|
||||||
|
|
||||||
Conséquences déjà constatées :
|
|
||||||
- une session utilisant les noms de l'AD (singuliers) déclenche un **HTTP 500**
|
|
||||||
sur chaque requête ;
|
|
||||||
- la liste d'entités documentée était fausse (`Aliases` n'existe pas, c'est
|
|
||||||
`Alias`) ;
|
|
||||||
- le MCP n'expose que 12 entités figées là où l'API en connaît 232.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Lot 2 — Correctif de fond
|
|
||||||
|
|
||||||
### L2.1 — Résolution des entités via l'API Metadata
|
|
||||||
|
|
||||||
Accepter `entity_type` au nom d'entité (`Container`) ou au nom de jeu
|
|
||||||
(`Containers`), insensible à la casse, et émettre `Context.{TableName}`. Cache
|
|
||||||
identique aux autres (TTL partagé, invalidation au changement de profil).
|
|
||||||
|
|
||||||
Sur nom inconnu, échouer **avant tout appel réseau**, avec un message
|
|
||||||
actionnable :
|
|
||||||
|
|
||||||
> « Item » n'existe pas dans le modèle Reading. Proches : ItemGroup, StockItem.
|
|
||||||
> 232 entités disponibles — utilisez `get_entity_metadata` pour la liste.
|
|
||||||
|
|
||||||
Supprime la cause des 500 et débloque 232 entités au lieu de 12.
|
|
||||||
|
|
||||||
### L2.2 — Rejeter les paramètres inconnus
|
|
||||||
|
|
||||||
Le SDK MCP ignore silencieusement les paramètres non déclarés : un appel
|
|
||||||
`read_recent_logs(lines: 60)` retombe sur le défaut `count = 100` sans le
|
|
||||||
moindre signal, et l'appelant conclut à un paramètre ignoré.
|
|
||||||
|
|
||||||
Ajouter `additionalProperties: false` aux 23 schémas d'outils.
|
|
||||||
|
|
||||||
C'est le correctif retenu **à la place** d'une uniformisation des noms de
|
|
||||||
paramètres : renommer casse les usages existants pour un gain cosmétique, alors
|
|
||||||
que la cause réelle est l'absence de signal.
|
|
||||||
|
|
||||||
### L2.3 — Arguments manquants : deux garde-fous
|
|
||||||
|
|
||||||
Découverts lors de la révision du lot 1 (24/08/2026), en bouclant sur les 23
|
|
||||||
outils avec des arguments vides. Préexistants au lot 1 (vérifié sur le diff) :
|
|
||||||
|
|
||||||
- `get_workflow_details` sans `workflow_id` renvoie `success: true` avec **le
|
|
||||||
premier workflow du cache**. Cause : `getWorkflowDetails()`
|
|
||||||
(`workflow-service.js`) compare `w.Code === workflowId` — or `Code`, `Id`,
|
|
||||||
`Name` n'existent pas sur les objets réels (clés minuscules, D5), donc
|
|
||||||
`undefined === undefined` matche. Supprimer les clés mortes de la
|
|
||||||
comparaison et rejeter un `workflow_id` absent avec un message actionnable.
|
|
||||||
- `search_logs` sans terme de recherche renvoie
|
|
||||||
`Search failed: Cannot read properties of undefined (reading 'toLowerCase')`
|
|
||||||
— le contrat d'erreur tient, mais le message viole la convention 4
|
|
||||||
(actionnable). Garde d'entrée avec le nom du paramètre attendu.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Lot 3 — Ergonomie et documentation
|
## Lot 3 — Ergonomie et documentation
|
||||||
|
|
||||||
|
Le lot 2 (résolution `Name` -> `TableName`, rejet des paramètres inconnus,
|
||||||
|
garde-fous d'arguments manquants) est livré — voir **D21** et **D23**.
|
||||||
|
|
||||||
### L3.1 — Bornage des sorties volumineuses
|
### L3.1 — Bornage des sorties volumineuses
|
||||||
|
|
||||||
- `get_system_parameters` : ajouter `limit` / `offset`, aujourd'hui absents
|
- `get_system_parameters` : ajouter `limit` / `offset`, aujourd'hui absents
|
||||||
@@ -99,29 +26,6 @@ outils avec des arguments vides. Préexistants au lot 1 (vérifié sur le diff)
|
|||||||
- Renvoyer `truncated: true` explicitement plutôt que de laisser le client se
|
- Renvoyer `truncated: true` explicitement plutôt que de laisser le client se
|
||||||
faire rejeter.
|
faire rejeter.
|
||||||
|
|
||||||
### L3.2 — Erreurs d'authentification muettes
|
|
||||||
|
|
||||||
`authenticate()` (`api-service.js`) ré-enveloppe l'erreur axios en
|
|
||||||
`new Error('Authentication failed: ' + error.message)` : le **corps de la
|
|
||||||
réponse du STS est perdu**, alors qu'il contient le diagnostic complet.
|
|
||||||
|
|
||||||
Preuve (24/08/2026, profil `AD`) : le smoke test affiche seulement
|
|
||||||
`Authentication failed: Request failed with status code 400` ; en rejouant la
|
|
||||||
même requête à la main, le corps était
|
|
||||||
`{"error":"invalid_request","error_description":"Tenant not found"}` — le
|
|
||||||
diagnostic exact, invisible depuis les outils comme depuis le smoke test.
|
|
||||||
|
|
||||||
Faire remonter `error.response.data` dans le message, comme L1.1 l'a fait pour
|
|
||||||
les outils. Même contrainte : ne jamais logguer les credentials.
|
|
||||||
|
|
||||||
### L3.3 — Documentation
|
|
||||||
|
|
||||||
- DECISIONS.md : **D21** la règle `TableName` (D22, le routage par table
|
|
||||||
explicite, a été livrée avec le lot 1).
|
|
||||||
- CLAUDE.md : corriger la liste d'entités (`Aliases` → `Alias`) et renvoyer vers
|
|
||||||
`get_entity_metadata` comme source de vérité.
|
|
||||||
- `wms://query-examples` : un exemple singulier/pluriel commenté.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Lot 4 — Modèle de données et applications
|
## Lot 4 — Modèle de données et applications
|
||||||
@@ -186,9 +90,8 @@ applications).
|
|||||||
`Application: "AGV"` qu'avec `Application: "EasyWMS"`. Le contexte de lecture est
|
`Application: "AGV"` qu'avec `Application: "EasyWMS"`. Le contexte de lecture est
|
||||||
**commun au tenant** : toutes les applications y déversent leurs entités.
|
**commun au tenant** : toutes les applications y déversent leurs entités.
|
||||||
|
|
||||||
Conséquence pour L2.1 : la table de résolution doit **agréger le Metadata de
|
Conséquence — traitée : la table de résolution (D21) **agrège le Metadata de
|
||||||
toutes les applications** (`GET /Metadata/Entities?applicationName=…` par
|
toutes les applications** déployées, et non le seul `EasyWMS`. Inutile en
|
||||||
application, 232 + 20 + 6 + …), et non se limiter à `EasyWMS`. Inutile en
|
|
||||||
revanche d'ajouter un paramètre `application` à `QueryExecute` : il ne changerait
|
revanche d'ajouter un paramètre `application` à `QueryExecute` : il ne changerait
|
||||||
rien.
|
rien.
|
||||||
|
|
||||||
@@ -279,8 +182,20 @@ plus riche que `/AD/api/Application/GetAll`), `GET /healthcheck?tenantCode=` et
|
|||||||
le tenant `AD`. L'hôte et le STS fonctionnent (LIMAGRAIN, même hôte, passe
|
le tenant `AD`. L'hôte et le STS fonctionnent (LIMAGRAIN, même hôte, passe
|
||||||
4/4) : c'est la valeur `AD_TENANT` du `.env` qui ne correspond plus à un
|
4/4) : c'est la valeur `AD_TENANT` du `.env` qui ne correspond plus à un
|
||||||
tenant existant. Correction côté propriétaire du dépôt (mettre à jour ou
|
tenant existant. Correction côté propriétaire du dépôt (mettre à jour ou
|
||||||
retirer le profil) — pas un bug du code. Le message opaque du smoke test est
|
retirer le profil) — pas un bug du code. Depuis L3.2, le corps de la réponse
|
||||||
traité à part (L3.2).
|
du STS (`Tenant not found`) remonte dans les erreurs d'outils et du smoke
|
||||||
|
test.
|
||||||
|
- **Bascule de profil concurrente aux appels en vol** (mesuré le 25/08/2026,
|
||||||
|
révision du lot 2). Le serveur traite les `tools/call` **en concurrence** :
|
||||||
|
en envoyant une rafale de requêtes dans une même session, les réponses
|
||||||
|
reviennent dans le désordre, et un `switch_wms_profile` émis pendant que des
|
||||||
|
requêtes sont en vol les fait partir sur le nouveau profil (observé : une
|
||||||
|
requête destinée à EUROTRAFIC exécutée sur l'hôte `10.255.255.2` après la
|
||||||
|
bascule suivante). Conséquence du singleton d'état global (D8). Sans gravité
|
||||||
|
pour un usage conversationnel séquentiel, mais Claude peut émettre des appels
|
||||||
|
d'outils **en parallèle** : à traiter si un cas réel de mélange de profils
|
||||||
|
est observé (piste : sérialiser les `tools/call` ou figer le profil résolu au
|
||||||
|
début de chaque appel).
|
||||||
- **`select_expression`** : les projections via le paramètre `Select` provoquent
|
- **`select_expression`** : les projections via le paramètre `Select` provoquent
|
||||||
des erreurs de compilation côté serveur (D13). Irritant principal restant.
|
des erreurs de compilation côté serveur (D13). Irritant principal restant.
|
||||||
- **Déploiement SSH sur la VM** : l'exécutable est validé, la configuration SSH
|
- **Déploiement SSH sur la VM** : l'exécutable est validé, la configuration SSH
|
||||||
|
|||||||
@@ -1,213 +0,0 @@
|
|||||||
# Passation — lot 2
|
|
||||||
|
|
||||||
Tu travailles sur `mcp-wms-api` : un serveur MCP (Node.js, CommonJS, stdio) qui
|
|
||||||
donne à Claude un accès en lecture à un WMS EasyWMS (Mecalux) via ses API REST.
|
|
||||||
Lis [../CLAUDE.md](../CLAUDE.md) et [../DECISIONS.md](../DECISIONS.md) avant de
|
|
||||||
toucher au code.
|
|
||||||
|
|
||||||
**Mission** : livrer le lot 2 de [../ROADMAP.md](../ROADMAP.md) — résolution
|
|
||||||
des noms d'entités via l'API Metadata (L2.1), rejet des paramètres inconnus
|
|
||||||
(L2.2), deux garde-fous d'arguments manquants (L2.3) — plus un petit correctif
|
|
||||||
du lot 3 (L3.2, erreurs d'authentification muettes).
|
|
||||||
|
|
||||||
**Hors périmètre** : tout le reste de la roadmap (L3.1, lot 4), le build pkg,
|
|
||||||
le déploiement. Ne pousse rien (`git push` interdit), ne touche pas au `.env`,
|
|
||||||
n'appelle jamais `execute_command` (il écrit dans le WMS).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Contexte matériel
|
|
||||||
|
|
||||||
- Profil de travail : `LIMAGRAIN` (profil par défaut), host `10.255.255.2`,
|
|
||||||
tenant `LIMAGRAI2512`. `EUROTRAFIC` fonctionne aussi (SaaS).
|
|
||||||
- **Le profil `AD` est cassé et c'est déjà diagnostiqué — ne le réinvestigue
|
|
||||||
pas.** Son tenant n'existe plus côté STS (`400 Tenant not found`). Conséquence
|
|
||||||
pratique : `npm test -- --all` échouera toujours sur AD ; la baseline se
|
|
||||||
mesure avec `npm test` (profil par défaut), attendu **4/4, code de sortie 0**.
|
|
||||||
- Baseline protocolaire à préserver : **23 outils**, **6 resources**, aucune
|
|
||||||
écriture sur stdout hors JSON-RPC.
|
|
||||||
|
|
||||||
Handshake + comptages :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
printf '%s\n%s\n%s\n%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}' '{"jsonrpc":"2.0","method":"notifications/initialized"}' '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' '{"jsonrpc":"2.0","id":3,"method":"resources/list"}' | node src/index.js 2>/dev/null | node -e "let b='';process.stdin.on('data',d=>b+=d).on('end',()=>{for(const l of b.split('\n').filter(Boolean)){const m=JSON.parse(l);if(m.id===2)console.log('tools:',m.result.tools.length);if(m.id===3)console.log('resources:',m.result.resources.length);}});"
|
|
||||||
```
|
|
||||||
|
|
||||||
Appeler un outil via le protocole (la seule preuve qu'un outil marche) :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
printf '%s\n%s\n%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}' '{"jsonrpc":"2.0","method":"notifications/initialized"}' '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"NOM","arguments":{}}}' | node src/index.js 2>/dev/null
|
|
||||||
```
|
|
||||||
|
|
||||||
## Contraintes non négociables
|
|
||||||
|
|
||||||
1. `console.error()` uniquement — une écriture sur stdout casse Claude Desktop
|
|
||||||
(D6).
|
|
||||||
2. Un outil ne plante jamais le serveur : erreurs en réponse structurée
|
|
||||||
`{ success: false, error, tool }`, `isError: true` (wrapper de
|
|
||||||
`src/index.js`).
|
|
||||||
3. Messages d'erreur actionnables : dire quoi faire ensuite (convention 4 de
|
|
||||||
CLAUDE.md).
|
|
||||||
4. Aucun accès base de données (D1).
|
|
||||||
5. Tout nouvel état lié au tenant s'invalide par abonnement
|
|
||||||
`profileManager.onSwitch()`, jamais à la main depuis un autre module (D8).
|
|
||||||
6. `QueryType: 0` (D3), pas de date relative (D12), 1000 lignes max.
|
|
||||||
|
|
||||||
**Numéros de décision réservés** : **D21** = règle `TableName` (L2.1),
|
|
||||||
**D23** = validation des paramètres (L2.2). N'en attribue pas d'autres sans les
|
|
||||||
réserver dans DECISIONS.md.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 0 — Vérifications avant de coder
|
|
||||||
|
|
||||||
Mesures déjà faites (24/08/2026, `LIMAGRAI2512`) à **confirmer**, pas à
|
|
||||||
réinvestiguer :
|
|
||||||
|
|
||||||
- `GET /Metadata/Entities` renvoie **232 entités** pour `EasyWMS`, chacune avec
|
|
||||||
`Name` et `TableName` ; `TableName` est **unique** sur les 232.
|
|
||||||
- Le mapping n'est **pas** une pluralisation : `Container` -> `Containers`,
|
|
||||||
mais `Alias` -> `Alias` (invariant), et `Item` n'existe pas.
|
|
||||||
- Le contexte de lecture est **commun au tenant** : la table de résolution doit
|
|
||||||
agréger le Metadata de **toutes** les applications
|
|
||||||
(`GET /Metadata/Entities?applicationName=…` — 9 applications, listées par
|
|
||||||
`POST /AD/api/Application/GetAll`, payload `null`). `CustomApp` renvoie
|
|
||||||
**0 entité** : c'est mesuré et normal, ne cherche pas pourquoi.
|
|
||||||
|
|
||||||
**V1 — le SDK MCP valide-t-il les schémas ?** Mesure indispensable avant L2.2 :
|
|
||||||
ajoute `additionalProperties: false` à **un** schéma, appelle l'outil via le
|
|
||||||
protocole avec un paramètre inconnu, et observe. Si le SDK rejette : L2.2 =
|
|
||||||
ajout sur les 23 schémas. Si le SDK laisse passer (probable) : la validation
|
|
||||||
doit se faire dans le wrapper `tools/call` de `src/index.js` (à la main ou via
|
|
||||||
une lib déjà présente dans l'arbre des dépendances — n'ajoute pas de dépendance
|
|
||||||
lourde sans nécessité). Time-box : 30 min ; si le comportement du SDK est
|
|
||||||
ambigu, note-le dans le commit et implémente la validation côté wrapper.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## L2.1 — Résolution des entités via l'API Metadata
|
|
||||||
|
|
||||||
**Problème.** `entity_type` est interpolé sans validation dans
|
|
||||||
`Context.{entity_type}` à deux endroits : `src/tools/api-tools.js:89` et
|
|
||||||
`src/services/wms-query-service.js:29` (et `:149`). Le nom attendu est le
|
|
||||||
`TableName` du Metadata, pas le nom d'entité de l'AD. Un mauvais nom part en
|
|
||||||
HTTP 500 côté WMS.
|
|
||||||
|
|
||||||
**Preuve.** `query_wms_entities("Container")` répond aujourd'hui :
|
|
||||||
|
|
||||||
```
|
|
||||||
Query failed for Container: POST …/QueryExecute failed (HTTP 500): …
|
|
||||||
Response body: … error CS1061: 'ApplicationReadingContext' ne contient pas de
|
|
||||||
définition pour 'Container' …
|
|
||||||
```
|
|
||||||
|
|
||||||
**À faire.**
|
|
||||||
- Une table de résolution `Name|TableName (insensible à la casse) -> TableName`,
|
|
||||||
construite depuis le Metadata **agrégé sur les 9 applications**, dans un
|
|
||||||
service (pas dupliquée dans les deux points d'interpolation).
|
|
||||||
- Cache : TTL partagé (`WORKFLOW_CACHE_TTL`), chargement paresseux,
|
|
||||||
**abonnement `onSwitch()`** pour l'invalidation (D8).
|
|
||||||
- Nom inconnu → échec **avant tout appel réseau**, message actionnable avec
|
|
||||||
suggestions proches et renvoi vers `get_entity_metadata`, par exemple :
|
|
||||||
« "Item" n'existe pas dans le modèle Reading. Proches : ItemGroup, StockItem.
|
|
||||||
232 entités disponibles — utilisez `get_entity_metadata` pour la liste. »
|
|
||||||
- Si le Metadata est injoignable au moment de résoudre : laisse passer le nom
|
|
||||||
tel quel (comportement actuel) plutôt que de bloquer tout — et dis-le dans la
|
|
||||||
réponse.
|
|
||||||
|
|
||||||
**Pente naturelle interdite** : ne réinvente pas une règle de pluralisation
|
|
||||||
(« ajouter un s sauf si… »). Seul `TableName` fait foi ; c'est un mapping mesuré,
|
|
||||||
pas une grammaire.
|
|
||||||
|
|
||||||
**Vérification attendue** (via le protocole, profil LIMAGRAIN) :
|
|
||||||
- `query_wms_entities("Container", limit 1)` → **succès**, 1 ligne (résolu en
|
|
||||||
`Containers`).
|
|
||||||
- `query_wms_entities("Alias")` → succès (invariant, pas de pluriel inventé).
|
|
||||||
- `query_wms_entities("Item")` → échec **sans appel réseau** (aucune ligne
|
|
||||||
`[API] POST` dans les logs stderr), message avec suggestions.
|
|
||||||
- `count_wms_entities("Product")` → succès, ~51 160.
|
|
||||||
- `get_entity_schema` et `call_query_api` bénéficient de la même résolution.
|
|
||||||
|
|
||||||
## L2.2 — Rejeter les paramètres inconnus
|
|
||||||
|
|
||||||
**Problème.** Le SDK ignore silencieusement les paramètres non déclarés :
|
|
||||||
`read_recent_logs(lines: 60)` retombe sur `count = 100` sans signal.
|
|
||||||
|
|
||||||
**À faire.** Selon le résultat de V1 : `additionalProperties: false` sur les
|
|
||||||
23 schémas, et/ou validation dans le wrapper `tools/call`. Le message de rejet
|
|
||||||
nomme le paramètre inconnu **et** les paramètres valides de l'outil.
|
|
||||||
|
|
||||||
**Décision déjà tranchée, ne pas rouvrir** : on ne renomme aucun paramètre
|
|
||||||
(cf. ROADMAP « Écarté »).
|
|
||||||
|
|
||||||
**Vérification attendue** : `read_recent_logs` avec `{"lines": 5}` → erreur
|
|
||||||
structurée nommant `lines` comme inconnu et `count` comme valide. Un appel
|
|
||||||
valide (`{"count": 5}`) → succès, 5 lignes.
|
|
||||||
|
|
||||||
## L2.3 — Garde-fous d'arguments manquants
|
|
||||||
|
|
||||||
**Preuves** (mesurées le 24/08/2026, préexistantes au lot 1) :
|
|
||||||
- `get_workflow_details` avec `{}` → `success: true` avec **le premier workflow
|
|
||||||
du cache**. Cause dans `workflow-service.js` `getWorkflowDetails()` : la
|
|
||||||
recherche compare `w.Id`, `w.Code`, `w.Name` — clés qui n'existent pas sur
|
|
||||||
les objets réels (minuscules, D5) — donc `undefined === undefined` matche.
|
|
||||||
- `search_logs` avec `{}` →
|
|
||||||
`Search failed: Cannot read properties of undefined (reading 'toLowerCase')`.
|
|
||||||
|
|
||||||
**À faire.** Supprimer les clés mortes (`Id`, `Code`, `Name` majuscules) de la
|
|
||||||
comparaison de `getWorkflowDetails()` ; rejeter `workflow_id` absent avec un
|
|
||||||
message actionnable. Garde d'entrée sur `search_logs` nommant le paramètre
|
|
||||||
attendu. (Si L2.2 rend certains cas impossibles via `required`, garde quand
|
|
||||||
même la garde côté code : la validation SDK n'est pas garantie.)
|
|
||||||
|
|
||||||
**Vérification attendue** : les deux appels avec `{}` renvoient une erreur
|
|
||||||
structurée actionnable ; `get_workflow_details` avec un id réel renvoie
|
|
||||||
toujours l'objet brut complet.
|
|
||||||
|
|
||||||
## L3.2 — Erreurs d'authentification muettes
|
|
||||||
|
|
||||||
**Preuve.** `authenticate()` dans `src/services/api-service.js` (le `catch`
|
|
||||||
vers la ligne 79) ré-enveloppe l'erreur axios en
|
|
||||||
`Authentication failed: Request failed with status code 400` et **jette le
|
|
||||||
corps de la réponse du STS**, qui contenait le diagnostic exact :
|
|
||||||
`{"error":"invalid_request","error_description":"Tenant not found"}` (mesuré
|
|
||||||
sur le profil `AD`).
|
|
||||||
|
|
||||||
**À faire.** Faire remonter statut + corps de réponse du STS dans le message,
|
|
||||||
sur le modèle de ce que L1.1 a fait pour `post()`/`get()` (réutilise le même
|
|
||||||
helper d'enrichissement si possible). **Jamais** les credentials ni les headers
|
|
||||||
dans le message. Même traitement pour `refreshOAuthToken()`.
|
|
||||||
|
|
||||||
**Vérification attendue** : `switch_wms_profile("AD")` puis un
|
|
||||||
`count_wms_entities("Products")` → la réponse d'outil contient
|
|
||||||
`Tenant not found` (et aucun mot de passe). Ne « répare » pas le profil AD :
|
|
||||||
son échec est précisément ce qui rend le correctif vérifiable.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Méthode
|
|
||||||
|
|
||||||
1. Phase 0 (V1) d'abord — L2.2 en dépend.
|
|
||||||
2. Ordre : L2.1, puis L2.2, puis L2.3, puis L3.2. Chaque correctif vérifié **en
|
|
||||||
exécution via le protocole** avant de passer au suivant.
|
|
||||||
3. Après tout changement de `src/index.js` ou des schémas : reboucler sur les
|
|
||||||
23 noms de `tools/list` et vérifier qu'aucun ne répond `Unknown tool`
|
|
||||||
(n'appelle pas `execute_command` — vérifie sa branche statiquement).
|
|
||||||
4. Baseline avant/après : handshake (23/6) + `npm test` (4/4, exit 0).
|
|
||||||
5. « Non résolu » est une réponse acceptable pour une investigation time-boxée ;
|
|
||||||
une hypothèse présentée comme solution ne l'est pas.
|
|
||||||
|
|
||||||
## Livraison
|
|
||||||
|
|
||||||
- Un commit par correctif (L2.1, L2.2, L2.3, L3.2), messages expliquant le
|
|
||||||
pourquoi, mesures dans le corps du message.
|
|
||||||
- Documentation dans les mêmes commits : **D21** et **D23** dans DECISIONS.md ;
|
|
||||||
CLAUDE.md — retirer le piège `entity_type` des « Points ouverts » et corriger
|
|
||||||
la liste d'entités (`Aliases` -> `Alias`, renvoi vers `get_entity_metadata`) ;
|
|
||||||
ROADMAP.md — retirer L2.1/L2.2/L2.3/L3.2 livrés (L3.3 documentation est
|
|
||||||
partiellement couverte par ces mises à jour : ajuste-la, ne la supprime que si
|
|
||||||
tout est fait, l'exemple `wms://query-examples` compris).
|
|
||||||
- Ne pousse pas. `.env` intact. Toute anomalie hors périmètre découverte en
|
|
||||||
route : dans ROADMAP.md, pas dans le code.
|
|
||||||
- Compte-rendu final : pour chaque correctif, la vérification attendue rejouée
|
|
||||||
et son résultat **mesuré** (colle la sortie), plus la baseline finale.
|
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
# Passation — lot 3 (L3.1, bornage des sorties volumineuses)
|
||||||
|
|
||||||
|
Tu travailles sur `mcp-wms-api` : un serveur MCP (Node.js, CommonJS, stdio) qui
|
||||||
|
donne à Claude un accès en lecture à un WMS EasyWMS (Mecalux) via ses API REST.
|
||||||
|
Lis [../CLAUDE.md](../CLAUDE.md) et [../DECISIONS.md](../DECISIONS.md) avant de
|
||||||
|
toucher au code.
|
||||||
|
|
||||||
|
**Mission** : borner les sorties des outils qui produisent aujourd'hui des
|
||||||
|
réponses de 50 000 à 70 000 caractères, rejetées par les clients MCP —
|
||||||
|
`get_system_parameters` et `search_logs` — avec un signal `truncated: true`
|
||||||
|
explicite plutôt qu'un rejet silencieux côté client.
|
||||||
|
|
||||||
|
**Hors périmètre** : tout le reste de la roadmap (lot 4 en entier). En
|
||||||
|
particulier, **n'explore pas `QueryExecuteStream`** — c'est une piste long
|
||||||
|
terme consignée en L4.5, pas ce lot. Ne touche pas aux limites des outils de
|
||||||
|
requête (`MAX_QUERY_ROWS` fait déjà le travail). Ne pousse rien
|
||||||
|
(`git push` interdit), ne touche pas au `.env`, n'appelle jamais
|
||||||
|
`execute_command` (il écrit dans le WMS).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contexte matériel
|
||||||
|
|
||||||
|
- Profil de travail : `LIMAGRAIN` (par défaut), host `10.255.255.2`, tenant
|
||||||
|
`LIMAGRAI2512`, `saas=false` — les outils de logs y fonctionnent.
|
||||||
|
- **Le profil `AD` est cassé et c'est diagnostiqué — ne le réinvestigue pas**
|
||||||
|
(tenant introuvable côté STS, point ouvert de la ROADMAP). Conséquence :
|
||||||
|
`npm test -- --all` échoue sur AD ; la baseline se mesure avec `npm test`
|
||||||
|
(profil par défaut), attendu **4/4, code de sortie 0**.
|
||||||
|
- Baseline protocolaire à préserver : **23 outils**, **6 resources**, aucune
|
||||||
|
écriture sur stdout hors JSON-RPC.
|
||||||
|
|
||||||
|
Handshake + comptages :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
printf '%s\n%s\n%s\n%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}' '{"jsonrpc":"2.0","method":"notifications/initialized"}' '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' '{"jsonrpc":"2.0","id":3,"method":"resources/list"}' | node src/index.js 2>/dev/null | node -e "let b='';process.stdin.on('data',d=>b+=d).on('end',()=>{for(const l of b.split('\n').filter(Boolean)){const m=JSON.parse(l);if(m.id===2)console.log('tools:',m.result.tools.length);if(m.id===3)console.log('resources:',m.result.resources.length);}});"
|
||||||
|
```
|
||||||
|
|
||||||
|
Mesurer la taille d'une réponse d'outil via le protocole (c'est la mesure qui
|
||||||
|
fait foi, pas une estimation) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
printf '%s\n%s\n%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}' '{"jsonrpc":"2.0","method":"notifications/initialized"}' '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"NOM","arguments":{}}}' | node src/index.js 2>/dev/null | node -e "let b='';process.stdin.on('data',d=>b+=d).on('end',()=>{for(const l of b.split('\n').filter(Boolean)){const m=JSON.parse(l);if(m.id===2)console.log('chars:',m.result.content[0].text.length);}});"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contraintes non négociables
|
||||||
|
|
||||||
|
1. `console.error()` uniquement — une écriture sur stdout casse Claude Desktop
|
||||||
|
(D6).
|
||||||
|
2. Un outil ne plante jamais le serveur : erreurs en réponse structurée
|
||||||
|
`{ success: false, error, tool }`, `isError: true` (wrapper de
|
||||||
|
`src/index.js`).
|
||||||
|
3. Messages d'erreur et hints actionnables : dire quoi faire ensuite.
|
||||||
|
4. Aucun accès base de données (D1).
|
||||||
|
5. **D23 : le wrapper `tools/call` valide les arguments contre les schémas.**
|
||||||
|
Tout paramètre que tu ajoutes (`limit`, `offset`…) doit être déclaré dans
|
||||||
|
l'`inputSchema` de l'outil, sinon le wrapper le rejettera comme inconnu.
|
||||||
|
C'est voulu — ne contourne pas la validation.
|
||||||
|
6. Les schémas portent `additionalProperties: false` — conserve-le.
|
||||||
|
|
||||||
|
**Numéro de décision réservé** : **D24** = contrat de troncature (si tu actes
|
||||||
|
un contrat commun — voir L3.1c). Vérifie que D23 est bien la dernière décision
|
||||||
|
avant d'écrire.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 0 — Confirmer les deux mesures
|
||||||
|
|
||||||
|
Mesures du 25/08/2026 (profil `LIMAGRAIN`, via le protocole) à **confirmer**
|
||||||
|
avec la commande de mesure ci-dessus, pas à réinvestiguer :
|
||||||
|
|
||||||
|
| Appel | Constaté |
|
||||||
|
|---|---|
|
||||||
|
| `get_system_parameters` `{}` | **70 141 caractères**, 168 paramètres, aucun `limit`/`offset` au schéma |
|
||||||
|
| `search_logs` `{"keyword":"Error"}` (défauts : `max_results` 50, `context_lines` 2) | **55 954 caractères**, 50 résultats |
|
||||||
|
|
||||||
|
Contexte : une sortie de ~70 000 caractères a déjà été **rejetée par le client
|
||||||
|
MCP** (constat du 24/08/2026 qui a motivé ce lot). L'ordre de grandeur cible
|
||||||
|
est ~20 000–25 000 caractères par réponse ; c'est un ordre de grandeur, pas un
|
||||||
|
chiffre sacré — ce qui compte est le comportement (borné + signalé), mesuré via
|
||||||
|
le protocole.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## L3.1a — `get_system_parameters` : pagination
|
||||||
|
|
||||||
|
**Problème.** L'outil (`src/tools/config-tools.js`) renvoie les 168 paramètres
|
||||||
|
fusionnés d'un bloc : 70 141 caractères sans filtre. Les filtres existants
|
||||||
|
(`warehouse`, `param_class`, `search`, `only_overridden`) réduisent la sortie
|
||||||
|
mais rien ne borne le cas sans filtre.
|
||||||
|
|
||||||
|
**À faire.** Ajouter `limit` (défaut raisonnable, ~50 — à ce volume la réponse
|
||||||
|
tient vers 21 000 caractères) et `offset` (défaut 0), **déclarés au schéma**
|
||||||
|
(contrainte 5). La réponse annonce toujours `totalParameters` (le total avant
|
||||||
|
pagination), le nombre renvoyé, et — quand la pagination a tronqué —
|
||||||
|
`truncated: true` avec un hint indiquant comment continuer (`offset` suivant)
|
||||||
|
ou réduire (`param_class`, `search`).
|
||||||
|
|
||||||
|
**Vérification attendue** (protocole, LIMAGRAIN) :
|
||||||
|
- `{}` → taille < 25 000 caractères, 50 paramètres renvoyés,
|
||||||
|
`totalParameters: 168`, `truncated: true`, hint présent.
|
||||||
|
- `{"limit": 200}` → les 168, `truncated: false` (ou champ absent — mais alors
|
||||||
|
cohérent partout).
|
||||||
|
- `{"offset": 160}` → 8 paramètres, pas de `truncated`.
|
||||||
|
- `{"search": "CROSSDOCK"}` → comportement inchangé sur petit résultat.
|
||||||
|
- `{"lines": 5}` → toujours rejeté par le wrapper (D23 intact).
|
||||||
|
|
||||||
|
## L3.1b — `search_logs` : garde-fou de taille
|
||||||
|
|
||||||
|
**Problème.** `max_results` existe (défaut 50) mais ne borne pas le **volume** :
|
||||||
|
les `context_lines` multiplient la taille des résultats. Mesuré : 55 954
|
||||||
|
caractères avec les seuls défauts.
|
||||||
|
|
||||||
|
**À faire.** Un garde-fou sur la taille cumulée de la réponse construite
|
||||||
|
(`src/tools/log-tools.js` / `src/services/log-service.js`) : au-delà du
|
||||||
|
plafond, couper la liste des résultats — des résultats **entiers**, ne coupe
|
||||||
|
pas un résultat au milieu de ses lignes de contexte — et poser
|
||||||
|
`truncated: true` + un compte des résultats retenus/écartés + un hint
|
||||||
|
(réduire `context_lines`, affiner `keyword`, baisser `max_results`).
|
||||||
|
Plafond : constante ou variable d'environnement avec défaut (cohérent avec le
|
||||||
|
style `MAX_QUERY_ROWS`/`QUERY_TIMEOUT` dans le code existant) — documente le
|
||||||
|
choix dans le commit.
|
||||||
|
|
||||||
|
**Pente naturelle interdite** : ne réduis pas silencieusement les défauts
|
||||||
|
(`max_results` 50, `context_lines` 2 restent tels quels) — le correctif est le
|
||||||
|
bornage signalé, pas un changement de comportement par défaut qui casserait
|
||||||
|
les usages existants.
|
||||||
|
|
||||||
|
**Vérification attendue** (protocole, LIMAGRAIN) :
|
||||||
|
- `{"keyword":"Error"}` → taille sous le plafond choisi, `truncated: true`,
|
||||||
|
compte écarté + hint présents.
|
||||||
|
- `{"keyword":"Error","max_results":3}` → petit, pas de troncature signalée.
|
||||||
|
- Un mot-clé sans occurrence → comportement inchangé (0 résultat, pas de
|
||||||
|
`truncated`).
|
||||||
|
|
||||||
|
## L3.1c — Cohérence du signal `truncated`
|
||||||
|
|
||||||
|
Si tu factorises un helper de troncature commun aux deux outils, actes le
|
||||||
|
contrat en **D24** dans DECISIONS.md (forme du signal : `truncated: true`,
|
||||||
|
compte total vs renvoyé, hint actionnable). Si les deux implémentations restent
|
||||||
|
locales et divergentes, harmonise au moins les noms de champs — deux
|
||||||
|
vocabulaires pour le même concept est exactement le genre de dérive qu'on
|
||||||
|
traque en révision. `read_recent_logs` peut bénéficier du même helper si c'est
|
||||||
|
gratuit ; ne le complexifie pas pour ça.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Méthode
|
||||||
|
|
||||||
|
1. Phase 0 d'abord (deux mesures, rejouées telles quelles).
|
||||||
|
2. L3.1a puis L3.1b puis L3.1c. Chaque correctif vérifié **en exécution via le
|
||||||
|
protocole** avant de passer au suivant — la taille en caractères de
|
||||||
|
`content[0].text` est la mesure qui fait foi.
|
||||||
|
3. Les schémas changent (nouveaux paramètres) : reboucler sur les 23 noms de
|
||||||
|
`tools/list` et vérifier qu'aucun ne répond `Unknown tool` (n'appelle pas
|
||||||
|
`execute_command` — vérifie sa branche statiquement), et vérifier qu'un
|
||||||
|
paramètre inconnu est toujours rejeté (D23).
|
||||||
|
4. Baseline avant/après : handshake (23/6) + `npm test` (4/4, exit 0).
|
||||||
|
5. « Non résolu » est une réponse acceptable pour une investigation time-boxée ;
|
||||||
|
une hypothèse présentée comme solution ne l'est pas.
|
||||||
|
|
||||||
|
## Livraison
|
||||||
|
|
||||||
|
- Un commit par correctif (L3.1a, L3.1b, L3.1c si D24), messages expliquant le
|
||||||
|
pourquoi, **mesures avant/après dans le corps du message** (tailles en
|
||||||
|
caractères, rejouées via le protocole).
|
||||||
|
- Documentation dans les mêmes commits : D24 si actée ; ROADMAP.md — retirer
|
||||||
|
L3.1 (le lot 3 devient vide : retire la section) ; CLAUDE.md ne change que si
|
||||||
|
tu ajoutes une variable d'environnement (la documenter dans la section des
|
||||||
|
réglages partagés).
|
||||||
|
- Ne pousse pas. `.env` intact. Toute anomalie hors périmètre découverte en
|
||||||
|
route : dans ROADMAP.md, pas dans le code.
|
||||||
|
- Compte-rendu final : pour chaque correctif, la vérification attendue rejouée
|
||||||
|
et son résultat **mesuré** (colle les tailles et les sorties), plus la
|
||||||
|
baseline finale.
|
||||||
@@ -94,6 +94,36 @@ for (const { moduleName, module } of TOOL_MODULES) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valide les arguments d'un appel d'outil contre son inputSchema (D23).
|
||||||
|
* Le SDK MCP ne valide pas les schémas d'entrée — mesuré le 24/08/2026 :
|
||||||
|
* `additionalProperties: false` est ignoré et un paramètre inconnu retombe
|
||||||
|
* silencieusement sur les défauts. La validation vit donc ici, pilotée par la
|
||||||
|
* même table que tools/list : schéma déclaré = contrat appliqué.
|
||||||
|
*/
|
||||||
|
function validateToolArgs(definition, args) {
|
||||||
|
const schema = definition.inputSchema || {};
|
||||||
|
const properties = schema.properties || {};
|
||||||
|
const validNames = Object.keys(properties);
|
||||||
|
const validList = validNames.length > 0 ? validNames.join(', ') : '(aucun)';
|
||||||
|
|
||||||
|
const unknown = Object.keys(args || {}).filter(key => !(key in properties));
|
||||||
|
if (unknown.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Paramètre(s) inconnu(s) pour ${definition.name} : ${unknown.join(', ')}. ` +
|
||||||
|
`Paramètres valides : ${validList}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const missing = (schema.required || []).filter(key => args?.[key] === undefined);
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Paramètre(s) requis manquant(s) pour ${definition.name} : ${missing.join(', ')}. ` +
|
||||||
|
`Paramètres valides : ${validList}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create MCP Server
|
// Create MCP Server
|
||||||
const server = new Server(
|
const server = new Server(
|
||||||
{
|
{
|
||||||
@@ -193,6 +223,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|||||||
`Unknown tool: ${name}. Available tools: ${Array.from(toolRegistry.keys()).join(', ')}`
|
`Unknown tool: ${name}. Available tools: ${Array.from(toolRegistry.keys()).join(', ')}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
validateToolArgs(entry.definition, args);
|
||||||
return await entry.module.executeTool(name, args);
|
return await entry.module.executeTool(name, args);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[Server] Error executing tool ${name}:`, error.message);
|
console.error(`[Server] Error executing tool ${name}:`, error.message);
|
||||||
|
|||||||
@@ -42,6 +42,20 @@ function getQueryExamples() {
|
|||||||
> (QueryType=Reading). Status/enum fields are **strings** (enum names), never integers.
|
> (QueryType=Reading). Status/enum fields are **strings** (enum names), never integers.
|
||||||
> Always verify enum values via \`docs://entities/\` or \`get_entity_metadata\` before filtering.
|
> Always verify enum values via \`docs://entities/\` or \`get_entity_metadata\` before filtering.
|
||||||
|
|
||||||
|
## Entity names — singular AD name or TableName, both accepted
|
||||||
|
|
||||||
|
\`entity_type\` is resolved case-insensitively against the Metadata API: the AD
|
||||||
|
entity name (singular) and the TableName both work. The mapping is **not** a
|
||||||
|
pluralisation rule — only the Metadata \`TableName\` is authoritative:
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
query_wms_entities(entity_type="Container") # AD name -> resolved to Containers
|
||||||
|
query_wms_entities(entity_type="Containers") # TableName -> used as-is
|
||||||
|
query_wms_entities(entity_type="Alias") # invariant: TableName IS "Alias" (no plural)
|
||||||
|
query_wms_entities(entity_type="Item") # fails fast: not in the Reading model,
|
||||||
|
# error lists close matches + get_entity_metadata
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Diagnostic Recipes
|
## Diagnostic Recipes
|
||||||
|
|||||||
+17
-10
@@ -77,8 +77,12 @@ class APIService {
|
|||||||
console.error(`[API] Authentication successful. Token expires in ~${this.tokenMaxAge}s`);
|
console.error(`[API] Authentication successful. Token expires in ~${this.tokenMaxAge}s`);
|
||||||
return this.token;
|
return this.token;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[API] Authentication failed:', error.message);
|
// Statut + corps de la réponse STS dans le message : c'est là que vit le
|
||||||
throw new Error(`Authentication failed: ${error.message}`);
|
// diagnostic ("Tenant not found", ...). Payload volontairement omis — il
|
||||||
|
// contient les credentials ; _enrichHttpError n'inclut jamais les headers.
|
||||||
|
const enriched = this._enrichHttpError(error, 'POST', profile.tokenUrl, undefined);
|
||||||
|
console.error('[API] Authentication failed:', enriched.message);
|
||||||
|
throw new Error(`Authentication failed: ${enriched.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,17 +92,17 @@ class APIService {
|
|||||||
async refreshOAuthToken() {
|
async refreshOAuthToken() {
|
||||||
const tokenAge = this.getTokenAge();
|
const tokenAge = this.getTokenAge();
|
||||||
|
|
||||||
try {
|
// If token is too old (>= maxAge), use password grant
|
||||||
// If token is too old (>= maxAge), use password grant
|
if (tokenAge >= this.tokenMaxAge) {
|
||||||
if (tokenAge >= this.tokenMaxAge) {
|
console.error('[API] Token too old, re-authenticating with password...');
|
||||||
console.error('[API] Token too old, re-authenticating with password...');
|
return await this.authenticate();
|
||||||
return await this.authenticate();
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
const profile = profileManager.getCurrent();
|
||||||
|
try {
|
||||||
// Otherwise use refresh_token grant
|
// Otherwise use refresh_token grant
|
||||||
console.error('[API] Refreshing token with refresh_token grant...');
|
console.error('[API] Refreshing token with refresh_token grant...');
|
||||||
|
|
||||||
const profile = profileManager.getCurrent();
|
|
||||||
const response = await this.httpClient.post(
|
const response = await this.httpClient.post(
|
||||||
profile.tokenUrl,
|
profile.tokenUrl,
|
||||||
new URLSearchParams({
|
new URLSearchParams({
|
||||||
@@ -120,7 +124,10 @@ class APIService {
|
|||||||
console.error('[API] Token refreshed successfully');
|
console.error('[API] Token refreshed successfully');
|
||||||
return this.token;
|
return this.token;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[API] Token refresh failed, re-authenticating:', error.message);
|
// Même enrichissement que authenticate() : statut + corps STS, sans le
|
||||||
|
// payload (refresh_token) ni les headers.
|
||||||
|
const enriched = this._enrichHttpError(error, 'POST', profile.tokenUrl, undefined);
|
||||||
|
console.error('[API] Token refresh failed, re-authenticating:', enriched.message);
|
||||||
return await this.authenticate();
|
return await this.authenticate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
/**
|
||||||
|
* Entity Resolver Service
|
||||||
|
* Résout un nom d'entité (Name de l'AD ou TableName, insensible à la casse)
|
||||||
|
* vers le TableName attendu par Context.{...} dans les requêtes LINQ (D21).
|
||||||
|
*
|
||||||
|
* Le mapping n'est PAS une pluralisation (Container -> Containers, mais
|
||||||
|
* Alias -> Alias) : seul le TableName de l'API Metadata fait foi. Le contexte
|
||||||
|
* de lecture étant commun au tenant, la table agrège le Metadata de toutes
|
||||||
|
* les applications installées.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const apiService = require('./api-service').getInstance();
|
||||||
|
const profileManager = require('../config/profile-manager');
|
||||||
|
|
||||||
|
// Cache state — même TTL que les autres caches (D10)
|
||||||
|
let resolutionMap = null; // Map lower(Name | TableName) -> TableName
|
||||||
|
let tableNames = null; // TableName[] triés (suggestions + comptage)
|
||||||
|
let cacheTimestamp = null;
|
||||||
|
const CACHE_TTL = parseInt(process.env.WORKFLOW_CACHE_TTL) || 3600000;
|
||||||
|
|
||||||
|
// La table de résolution est par tenant — invalidée à chaque bascule (D8).
|
||||||
|
profileManager.onSwitch(() => invalidateCache());
|
||||||
|
|
||||||
|
function isCacheValid() {
|
||||||
|
if (!resolutionMap || !cacheTimestamp) return false;
|
||||||
|
return Date.now() - cacheTimestamp < CACHE_TTL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charge la table de résolution depuis l'API Metadata, agrégée sur toutes
|
||||||
|
* les applications installées.
|
||||||
|
* GET /configuration/applications ne liste que les applications déployées
|
||||||
|
* avec une version — les applications EasyBuilder sans contexte requêtable
|
||||||
|
* (CustomApp...) n'y figurent pas et ne fournissent de toute façon aucune
|
||||||
|
* entité Metadata.
|
||||||
|
*/
|
||||||
|
async function loadResolutionMap() {
|
||||||
|
if (isCacheValid()) return;
|
||||||
|
|
||||||
|
console.error('[EntityResolver] Cache expired or empty, fetching Metadata...');
|
||||||
|
|
||||||
|
const apps = await apiService.get('/configuration/applications');
|
||||||
|
const appNames = (Array.isArray(apps) ? apps : [])
|
||||||
|
.map(a => a.Name || a.name)
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (appNames.length === 0) {
|
||||||
|
throw new Error('GET /configuration/applications returned no application');
|
||||||
|
}
|
||||||
|
|
||||||
|
const map = new Map();
|
||||||
|
const names = new Set();
|
||||||
|
|
||||||
|
for (const app of appNames) {
|
||||||
|
const entities = await apiService.getMetadataEntities(app);
|
||||||
|
for (const e of (Array.isArray(entities) ? entities : [])) {
|
||||||
|
const tableName = e.TableName || e.tableName;
|
||||||
|
const name = e.Name || e.name;
|
||||||
|
if (!tableName) continue;
|
||||||
|
names.add(tableName);
|
||||||
|
map.set(tableName.toLowerCase(), tableName);
|
||||||
|
if (name) map.set(name.toLowerCase(), tableName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (names.size === 0) {
|
||||||
|
throw new Error('Metadata API returned no entity for any application');
|
||||||
|
}
|
||||||
|
|
||||||
|
resolutionMap = map;
|
||||||
|
tableNames = Array.from(names).sort();
|
||||||
|
cacheTimestamp = Date.now();
|
||||||
|
console.error(`[EntityResolver] Cached ${tableNames.length} entities from ${appNames.length} application(s)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Distance de Levenshtein — uniquement pour suggérer des noms proches.
|
||||||
|
*/
|
||||||
|
function levenshtein(a, b) {
|
||||||
|
const m = a.length;
|
||||||
|
const n = b.length;
|
||||||
|
let prev = Array.from({ length: n + 1 }, (_, j) => j);
|
||||||
|
for (let i = 1; i <= m; i++) {
|
||||||
|
const curr = [i];
|
||||||
|
for (let j = 1; j <= n; j++) {
|
||||||
|
curr[j] = Math.min(
|
||||||
|
prev[j] + 1,
|
||||||
|
curr[j - 1] + 1,
|
||||||
|
prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
prev = curr;
|
||||||
|
}
|
||||||
|
return prev[n];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suggère les TableName les plus proches d'un nom inconnu :
|
||||||
|
* correspondances par sous-chaîne d'abord, puis distance d'édition.
|
||||||
|
*/
|
||||||
|
function suggestClosest(input, limit = 5) {
|
||||||
|
const lower = input.toLowerCase();
|
||||||
|
const scored = tableNames.map(tn => {
|
||||||
|
const l = tn.toLowerCase();
|
||||||
|
const score = (l.includes(lower) || lower.includes(l))
|
||||||
|
? Math.abs(l.length - lower.length) // sous-chaîne : quasi-match
|
||||||
|
: 100 + levenshtein(lower, l); // sinon : distance d'édition
|
||||||
|
return { tn, score };
|
||||||
|
});
|
||||||
|
scored.sort((a, b) => a.score - b.score || a.tn.localeCompare(b.tn));
|
||||||
|
const maxEditDistance = Math.max(3, Math.floor(lower.length / 2));
|
||||||
|
return scored
|
||||||
|
.filter(s => s.score < 100 + maxEditDistance)
|
||||||
|
.slice(0, limit)
|
||||||
|
.map(s => s.tn);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Résout un nom d'entité vers son TableName.
|
||||||
|
*
|
||||||
|
* @param {string} entityType - Name AD ou TableName, insensible à la casse
|
||||||
|
* @returns {Promise<{tableName: string, warning?: string}>}
|
||||||
|
* - nom connu : { tableName } (le TableName exact)
|
||||||
|
* - Metadata injoignable : { tableName: entityType, warning } — on laisse
|
||||||
|
* passer le nom tel quel (comportement historique) plutôt que de tout
|
||||||
|
* bloquer, et on le dit dans la réponse
|
||||||
|
* @throws {Error} nom inconnu du modèle Reading — AVANT tout appel réseau de
|
||||||
|
* requête, avec suggestions proches et renvoi vers get_entity_metadata
|
||||||
|
*/
|
||||||
|
async function resolveEntityType(entityType) {
|
||||||
|
if (!entityType || typeof entityType !== 'string' || entityType.trim() === '') {
|
||||||
|
throw new Error('entity_type est requis. Utilisez get_entity_metadata pour la liste des entités interrogeables.');
|
||||||
|
}
|
||||||
|
const trimmed = entityType.trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loadResolutionMap();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[EntityResolver] Metadata unreachable, passing "${trimmed}" through as-is: ${err.message}`);
|
||||||
|
return {
|
||||||
|
tableName: trimmed,
|
||||||
|
warning: `Le nom d'entité "${trimmed}" n'a pas pu être validé (API Metadata injoignable : ${err.message}). Il est transmis tel quel au WMS.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableName = resolutionMap.get(trimmed.toLowerCase());
|
||||||
|
if (tableName) {
|
||||||
|
return { tableName };
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestions = suggestClosest(trimmed);
|
||||||
|
const closest = suggestions.length > 0 ? ` Proches : ${suggestions.join(', ')}.` : '';
|
||||||
|
throw new Error(
|
||||||
|
`"${trimmed}" n'existe pas dans le modèle Reading.${closest} ` +
|
||||||
|
`${tableNames.length} entités disponibles — utilisez get_entity_metadata pour la liste.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalide la table de résolution (bascule de profil).
|
||||||
|
*/
|
||||||
|
function invalidateCache() {
|
||||||
|
resolutionMap = null;
|
||||||
|
tableNames = null;
|
||||||
|
cacheTimestamp = null;
|
||||||
|
console.error('[EntityResolver] Cache cleared');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* État du cache (exposé par get_application_summary si besoin).
|
||||||
|
*/
|
||||||
|
function getCacheStatus() {
|
||||||
|
return {
|
||||||
|
cached: resolutionMap !== null,
|
||||||
|
count: tableNames ? tableNames.length : 0,
|
||||||
|
timestamp: cacheTimestamp,
|
||||||
|
age: cacheTimestamp ? Math.floor((Date.now() - cacheTimestamp) / 1000) : null,
|
||||||
|
valid: isCacheValid(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
resolveEntityType,
|
||||||
|
invalidateCache,
|
||||||
|
getCacheStatus,
|
||||||
|
};
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const apiService = require('./api-service').getInstance();
|
const apiService = require('./api-service').getInstance();
|
||||||
|
const entityResolver = require('./entity-resolver');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a LINQ select expression
|
* Build a LINQ select expression
|
||||||
@@ -20,13 +21,17 @@ const apiService = require('./api-service').getInstance();
|
|||||||
* @param {number} limit - Result limit
|
* @param {number} limit - Result limit
|
||||||
*/
|
*/
|
||||||
async function queryEntities(entityType, selectExpression = 'z => z', filter = null, limit = 100) {
|
async function queryEntities(entityType, selectExpression = 'z => z', filter = null, limit = 100) {
|
||||||
|
// Résolution Name/TableName -> TableName (D21). Un nom inconnu échoue ici,
|
||||||
|
// avant tout appel réseau de requête — l'erreur porte les suggestions.
|
||||||
|
const { tableName, warning } = await entityResolver.resolveEntityType(entityType);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Enforce max limit
|
// Enforce max limit
|
||||||
const maxLimit = parseInt(process.env.MAX_QUERY_ROWS) || 1000;
|
const maxLimit = parseInt(process.env.MAX_QUERY_ROWS) || 1000;
|
||||||
const actualLimit = Math.min(limit, maxLimit);
|
const actualLimit = Math.min(limit, maxLimit);
|
||||||
|
|
||||||
// Build expression: Context + optional Where + OrderBy (required by EF when Take is used)
|
// Build expression: Context + optional Where + OrderBy (required by EF when Take is used)
|
||||||
let expression = `Context.${entityType}`;
|
let expression = `Context.${tableName}`;
|
||||||
if (filter) {
|
if (filter) {
|
||||||
const whereExpr = /^\s*\w+\s*=>/.test(filter) ? filter : `z => ${filter}`;
|
const whereExpr = /^\s*\w+\s*=>/.test(filter) ? filter : `z => ${filter}`;
|
||||||
expression += `.Where(${whereExpr})`;
|
expression += `.Where(${whereExpr})`;
|
||||||
@@ -43,6 +48,8 @@ async function queryEntities(entityType, selectExpression = 'z => z', filter = n
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
entityType,
|
entityType,
|
||||||
|
resolvedTableName: tableName,
|
||||||
|
...(warning ? { warning } : {}),
|
||||||
expression,
|
expression,
|
||||||
limit: actualLimit,
|
limit: actualLimit,
|
||||||
count: Array.isArray(result) ? result.length : 0,
|
count: Array.isArray(result) ? result.length : 0,
|
||||||
@@ -144,9 +151,13 @@ async function getEntitySchema(entityType) {
|
|||||||
* @param {string|null} filter - Optional filter
|
* @param {string|null} filter - Optional filter
|
||||||
*/
|
*/
|
||||||
async function countEntities(entityType, filter = null) {
|
async function countEntities(entityType, filter = null) {
|
||||||
|
// Résolution Name/TableName -> TableName (D21) — échec avant appel réseau
|
||||||
|
// sur nom inconnu.
|
||||||
|
const { tableName, warning } = await entityResolver.resolveEntityType(entityType);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Build: Context.Entity.Where(...).Count()
|
// Build: Context.Entity.Where(...).Count()
|
||||||
const parts = [`Context.${entityType}`];
|
const parts = [`Context.${tableName}`];
|
||||||
if (filter) {
|
if (filter) {
|
||||||
const whereExpr = /^\s*\w+\s*=>/.test(filter) ? filter : `z => ${filter}`;
|
const whereExpr = /^\s*\w+\s*=>/.test(filter) ? filter : `z => ${filter}`;
|
||||||
parts.push(`Where(${whereExpr})`);
|
parts.push(`Where(${whereExpr})`);
|
||||||
@@ -160,6 +171,8 @@ async function countEntities(entityType, filter = null) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
entityType,
|
entityType,
|
||||||
|
resolvedTableName: tableName,
|
||||||
|
...(warning ? { warning } : {}),
|
||||||
filter,
|
filter,
|
||||||
count
|
count
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -132,22 +132,24 @@ async function searchWorkflows(query, category = null, limit = 50) {
|
|||||||
* @param {string|number} workflowId - Workflow ID
|
* @param {string|number} workflowId - Workflow ID
|
||||||
*/
|
*/
|
||||||
async function getWorkflowDetails(workflowId) {
|
async function getWorkflowDetails(workflowId) {
|
||||||
|
// Garde d'entrée : sans elle, un workflow_id absent matchait le premier
|
||||||
|
// workflow du cache (undefined === undefined sur les clés mortes ci-dessous).
|
||||||
|
if (workflowId == null || workflowId === '') {
|
||||||
|
throw new Error('workflow_id est requis (id ou nom exact du workflow). Utilisez search_workflows pour le trouver.');
|
||||||
|
}
|
||||||
|
|
||||||
const workflows = await fetchAllWorkflows();
|
const workflows = await fetchAllWorkflows();
|
||||||
|
|
||||||
// Try to find by Id, id, Code, code, Name, or name
|
// Clés réelles de l'API AD (minuscules, D5) : id, name. Les variantes
|
||||||
|
// Id/Code/Name n'existent pas sur ces objets — les comparer faisait matcher
|
||||||
|
// undefined === undefined dès que workflow_id manquait.
|
||||||
const workflow = workflows.find(w =>
|
const workflow = workflows.find(w =>
|
||||||
w.id === workflowId ||
|
w.id === workflowId ||
|
||||||
w.Id === workflowId ||
|
|
||||||
w.id === parseInt(workflowId) ||
|
|
||||||
w.Id === parseInt(workflowId) ||
|
|
||||||
w.Code === workflowId ||
|
|
||||||
w.code === workflowId ||
|
|
||||||
w.Name === workflowId ||
|
|
||||||
w.name === workflowId
|
w.name === workflowId
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!workflow) {
|
if (!workflow) {
|
||||||
throw new Error(`Workflow not found: ${workflowId}`);
|
throw new Error(`Workflow not found: ${workflowId}. Utilisez search_workflows pour trouver l'id ou le nom exact.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return workflow;
|
return workflow;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ function listTools() {
|
|||||||
description: 'Get summary of Application Dictionary elements. Shows count of cached elements per type (Commands, Queries, Dialogs, Views, etc.). Only counts already-loaded types to avoid long waits.',
|
description: 'Get summary of Application Dictionary elements. Shows count of cached elements per type (Commands, Queries, Dialogs, Views, etc.). Only counts already-loaded types to avoid long waits.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
properties: {},
|
properties: {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -23,6 +24,7 @@ function listTools() {
|
|||||||
description: 'Get all elements of a specific type from Application Dictionary. Supports: Command, Query, Dialog, View, Entity, Event, Hook, Report, Dashboard, and 11 other types (20 total). Elements are lazy-loaded and cached for 1 hour.',
|
description: 'Get all elements of a specific type from Application Dictionary. Supports: Command, Query, Dialog, View, Entity, Event, Hook, Report, Dashboard, and 11 other types (20 total). Elements are lazy-loaded and cached for 1 hour.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
properties: {
|
properties: {
|
||||||
element_type: {
|
element_type: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
@@ -42,6 +44,7 @@ function listTools() {
|
|||||||
description: 'Search Application Dictionary elements by name, description, or code. Searches within a specific element type.',
|
description: 'Search Application Dictionary elements by name, description, or code. Searches within a specific element type.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
properties: {
|
properties: {
|
||||||
element_type: {
|
element_type: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
@@ -65,6 +68,7 @@ function listTools() {
|
|||||||
description: 'Get detailed information about a specific AD element by ID or name',
|
description: 'Get detailed information about a specific AD element by ID or name',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
properties: {
|
properties: {
|
||||||
element_type: {
|
element_type: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
@@ -83,6 +87,7 @@ function listTools() {
|
|||||||
description: 'List all available Application Dictionary element types',
|
description: 'List all available Application Dictionary element types',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
properties: {},
|
properties: {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+11
-2
@@ -1,4 +1,5 @@
|
|||||||
const apiService = require('../services/api-service').getInstance();
|
const apiService = require('../services/api-service').getInstance();
|
||||||
|
const entityResolver = require('../services/entity-resolver');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tools MCP pour interagir avec les APIs WMS
|
* Tools MCP pour interagir avec les APIs WMS
|
||||||
@@ -15,10 +16,11 @@ function listTools() {
|
|||||||
description: 'Appelle l\'API Query du WMS pour interroger des entités (Containers, Stocks, Tasks, Products, etc.)',
|
description: 'Appelle l\'API Query du WMS pour interroger des entités (Containers, Stocks, Tasks, Products, etc.)',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
properties: {
|
properties: {
|
||||||
entity_type: {
|
entity_type: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
description: 'Type d\'entité (Containers, Stocks, ProductLocations, Tasks, Products, Accounts, Suppliers, Kits, Aliases, InboundOrders, Receptions, OutboundOrders)',
|
description: 'Type d\'entité — nom AD (Container) ou TableName (Containers), insensible à la casse, résolu via l\'API Metadata. Ex: Containers, Stocks, ProductLocations, Tasks, Products, Accounts, Suppliers, Kits, Alias, InboundOrders, Receptions, OutboundOrders. Liste complète via get_entity_metadata.',
|
||||||
},
|
},
|
||||||
expression: {
|
expression: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
@@ -43,6 +45,7 @@ function listTools() {
|
|||||||
description: 'Exécute une commande WMS (ATTENTION: peut modifier des données). Toujours récupérer la commande via get_ad_elements/get_ad_element_details avant d\'exécuter.',
|
description: 'Exécute une commande WMS (ATTENTION: peut modifier des données). Toujours récupérer la commande via get_ad_elements/get_ad_element_details avant d\'exécuter.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
properties: {
|
properties: {
|
||||||
command_name: {
|
command_name: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
@@ -85,8 +88,12 @@ async function callQueryAPI(args) {
|
|||||||
const { entity_type, expression = 'z => z', filter, limit = 100 } = args;
|
const { entity_type, expression = 'z => z', filter, limit = 100 } = args;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Résolution Name/TableName -> TableName (D21) — échec avant appel réseau
|
||||||
|
// sur nom inconnu.
|
||||||
|
const { tableName, warning } = await entityResolver.resolveEntityType(entity_type);
|
||||||
|
|
||||||
// Expression = Context.Entity + optional Where + OrderBy (required by EF when Take is used)
|
// Expression = Context.Entity + optional Where + OrderBy (required by EF when Take is used)
|
||||||
let linqExpression = `Context.${entity_type}`;
|
let linqExpression = `Context.${tableName}`;
|
||||||
if (filter) {
|
if (filter) {
|
||||||
const whereExpr = /^\s*\w+\s*=>/.test(filter) ? filter : `z => ${filter}`;
|
const whereExpr = /^\s*\w+\s*=>/.test(filter) ? filter : `z => ${filter}`;
|
||||||
linqExpression += `.Where(${whereExpr})`;
|
linqExpression += `.Where(${whereExpr})`;
|
||||||
@@ -106,6 +113,8 @@ async function callQueryAPI(args) {
|
|||||||
{
|
{
|
||||||
success: true,
|
success: true,
|
||||||
entityType: entity_type,
|
entityType: entity_type,
|
||||||
|
resolvedTableName: tableName,
|
||||||
|
...(warning ? { warning } : {}),
|
||||||
result,
|
result,
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ Examples:
|
|||||||
- get_system_parameters(search="CROSSDOCK") — parameters whose code/description matches`,
|
- get_system_parameters(search="CROSSDOCK") — parameters whose code/description matches`,
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
properties: {
|
properties: {
|
||||||
warehouse: {
|
warehouse: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ function listTools() {
|
|||||||
description: 'Lit les dernières lignes des fichiers de logs',
|
description: 'Lit les dernières lignes des fichiers de logs',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
properties: {
|
properties: {
|
||||||
count: {
|
count: {
|
||||||
type: 'number',
|
type: 'number',
|
||||||
@@ -33,6 +34,7 @@ function listTools() {
|
|||||||
description: 'Liste tous les fichiers de logs disponibles sous LOGS_PATH avec leur taille et date de modification',
|
description: 'Liste tous les fichiers de logs disponibles sous LOGS_PATH avec leur taille et date de modification',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
properties: {},
|
properties: {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -41,6 +43,7 @@ function listTools() {
|
|||||||
description: 'Recherche un mot-clé dans les fichiers de logs avec contexte',
|
description: 'Recherche un mot-clé dans les fichiers de logs avec contexte',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
properties: {
|
properties: {
|
||||||
keyword: {
|
keyword: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
@@ -171,6 +174,12 @@ async function searchLogs(args) {
|
|||||||
const { keyword, max_results = 50, context_lines = 2 } = args;
|
const { keyword, max_results = 50, context_lines = 2 } = args;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Garde d'entrée : sans elle, un keyword absent plantait en
|
||||||
|
// "Cannot read properties of undefined (reading 'toLowerCase')".
|
||||||
|
if (typeof keyword !== 'string' || keyword.trim() === '') {
|
||||||
|
throw new Error('Le paramètre "keyword" (mot-clé à rechercher) est requis. Exemple : search_logs({"keyword": "Execute error"}).');
|
||||||
|
}
|
||||||
|
|
||||||
const result = await logService.searchLogs(keyword, max_results, context_lines);
|
const result = await logService.searchLogs(keyword, max_results, context_lines);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ Use this to discover the exact field names and types for any entity before build
|
|||||||
- With entity_name (partial match ok, case-insensitive): returns field names + types for that entity`,
|
- With entity_name (partial match ok, case-insensitive): returns field names + types for that entity`,
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
properties: {
|
properties: {
|
||||||
entity_name: {
|
entity_name: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
@@ -34,6 +35,7 @@ Examples:
|
|||||||
- generic_search() — list available search categories`,
|
- generic_search() — list available search categories`,
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
properties: {
|
properties: {
|
||||||
query: {
|
query: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ function listTools() {
|
|||||||
description: 'List all WMS profiles configured in .env (AD, LIMAGRAIN, ...) with their host and tenant. Use this to see which WMS backends are available.',
|
description: 'List all WMS profiles configured in .env (AD, LIMAGRAIN, ...) with their host and tenant. Use this to see which WMS backends are available.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
properties: {},
|
properties: {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -24,6 +25,7 @@ function listTools() {
|
|||||||
description: 'Return the currently active WMS profile (name, host, tenant, application). If no profile is active, returns an error explaining that switch_wms_profile must be called first.',
|
description: 'Return the currently active WMS profile (name, host, tenant, application). If no profile is active, returns an error explaining that switch_wms_profile must be called first.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
properties: {},
|
properties: {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -32,6 +34,7 @@ function listTools() {
|
|||||||
description: 'Switch the active WMS profile. Resets the OAuth token and clears workflow/AD caches so the next API call targets the new backend. Use list_wms_profiles to see valid names.',
|
description: 'Switch the active WMS profile. Resets the OAuth token and clears workflow/AD caches so the next API call targets the new backend. Use list_wms_profiles to see valid names.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
properties: {
|
properties: {
|
||||||
profile: {
|
profile: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ function listTools() {
|
|||||||
name: 'query_wms_entities',
|
name: 'query_wms_entities',
|
||||||
description: `Query WMS entities using LINQ expressions. Returns rows (up to 1000).
|
description: `Query WMS entities using LINQ expressions. Returns rows (up to 1000).
|
||||||
Uses QueryExecute with QueryType=Reading — status fields are STRINGS (enum names, not integers).
|
Uses QueryExecute with QueryType=Reading — status fields are STRINGS (enum names, not integers).
|
||||||
Common entities: Products, Containers, Tasks, Stocks, ProductLocations, Location, InboundOrders, OutboundOrders, Receptions, Accounts, Suppliers, Kits, Aliases.
|
Common entities: Products, Containers, Tasks, Stocks, ProductLocations, Locations, InboundOrders, OutboundOrders, Receptions, Accounts, Suppliers, Kits, Alias. Full list via get_entity_metadata.
|
||||||
|
entity_type accepts the AD entity name (Container) or the TableName (Containers), case-insensitive — resolved via the Metadata API.
|
||||||
|
|
||||||
IMPORTANT — before building a filter with a status/enum field:
|
IMPORTANT — before building a filter with a status/enum field:
|
||||||
1. Check docs first: read resource docs://entities/ (e.g. easywms_reading_entites_outboundorder_OutboundOrderStatus for OutboundOrders)
|
1. Check docs first: read resource docs://entities/ (e.g. easywms_reading_entites_outboundorder_OutboundOrderStatus for OutboundOrders)
|
||||||
@@ -23,10 +24,11 @@ IMPORTANT — before building a filter with a status/enum field:
|
|||||||
Never guess enum string values — they differ between Reading and Writing models.`,
|
Never guess enum string values — they differ between Reading and Writing models.`,
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
properties: {
|
properties: {
|
||||||
entity_type: {
|
entity_type: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
description: 'Entity type (Products, Containers, Tasks, Stocks, ProductLocations, InboundOrders, OutboundOrders, Accounts, Suppliers, Kits, Aliases, Receptions)',
|
description: 'Entity type — AD name (Container) or TableName (Containers), case-insensitive, resolved via the Metadata API. E.g. Products, Containers, Tasks, Stocks, ProductLocations, InboundOrders, OutboundOrders, Accounts, Suppliers, Kits, Alias, Receptions.',
|
||||||
},
|
},
|
||||||
select_expression: {
|
select_expression: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
@@ -51,6 +53,7 @@ Never guess enum string values — they differ between Reading and Writing model
|
|||||||
description: 'Get the schema/structure of a WMS entity by querying one sample record',
|
description: 'Get the schema/structure of a WMS entity by querying one sample record',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
properties: {
|
properties: {
|
||||||
entity_type: {
|
entity_type: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
@@ -83,6 +86,7 @@ Verified values (curl-tested):
|
|||||||
(sur un emplacement: ajouter && z.LocationCode == "X")`,
|
(sur un emplacement: ajouter && z.LocationCode == "X")`,
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
properties: {
|
properties: {
|
||||||
entity_type: {
|
entity_type: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
@@ -101,6 +105,7 @@ Verified values (curl-tested):
|
|||||||
description: 'Search for a keyword across multiple WMS entities',
|
description: 'Search for a keyword across multiple WMS entities',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
properties: {
|
properties: {
|
||||||
keyword: {
|
keyword: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ function listTools() {
|
|||||||
description: 'Search workflows by name, description, or code. Returns matching workflows with metadata. Workflows are lazy-loaded from API on first request and cached for 1 hour.',
|
description: 'Search workflows by name, description, or code. Returns matching workflows with metadata. Workflows are lazy-loaded from API on first request and cached for 1 hour.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
properties: {
|
properties: {
|
||||||
query: {
|
query: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
@@ -37,6 +38,7 @@ function listTools() {
|
|||||||
description: 'Get full details of a specific workflow by ID or code',
|
description: 'Get full details of a specific workflow by ID or code',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
properties: {
|
properties: {
|
||||||
workflow_id: {
|
workflow_id: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
@@ -51,6 +53,7 @@ function listTools() {
|
|||||||
description: 'List workflow groupings by applicationName. Workflows have no category field in the AD API — applicationName is the only grouping available, and all workflows of the active application share the same value.',
|
description: 'List workflow groupings by applicationName. Workflows have no category field in the AD API — applicationName is the only grouping available, and all workflows of the active application share the same value.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
properties: {},
|
properties: {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user