Compare commits
4 Commits
7b25e79e98
...
702c2ecd2a
| Author | SHA1 | Date | |
|---|---|---|---|
| 702c2ecd2a | |||
| b37c2ac251 | |||
| d0a6cc1a0b | |||
| c4d6b5650e |
@@ -146,6 +146,10 @@ explicite (D9). Par défaut `false`.
|
||||
**`LOGS_PATH`** accepte le placeholder `{host}`, substitué par le host du profil
|
||||
actif à chaque appel.
|
||||
|
||||
**`MAX_LOG_SEARCH_CHARS`** (défaut 25 000) : plafond en caractères de la réponse
|
||||
de `search_logs` — au-delà, des résultats entiers sont écartés et signalés
|
||||
(`truncated`, D24).
|
||||
|
||||
**Au runtime.** `profile-manager` est un singleton d'état global. Les services
|
||||
s'abonnent via `onSwitch()` pour invalider ce qui dépend du tenant :
|
||||
|
||||
|
||||
@@ -440,3 +440,43 @@ 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).
|
||||
|
||||
+1
-17
@@ -12,22 +12,6 @@ contient que ce qui reste à faire.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
- `get_system_parameters` : ajouter `limit` / `offset`, aujourd'hui absents
|
||||
(sortie constatée : 70 000 caractères, rejetée par le client).
|
||||
- `search_logs` : garde-fou de taille. `max_results` existe déjà, mais les
|
||||
`context_lines` multiplient le volume (88 000 caractères pour 50 résultats).
|
||||
- Renvoyer `truncated: true` explicitement plutôt que de laisser le client se
|
||||
faire rejeter.
|
||||
|
||||
---
|
||||
|
||||
## Lot 4 — Modèle de données et applications
|
||||
|
||||
Deux angles morts constatés le 24/08/2026, plus larges que les lots 2 et 3. Les
|
||||
@@ -151,7 +135,7 @@ La référence de l'API documente des champs que le MCP n'envoie jamais :
|
||||
| `Parameters` | requêtes **paramétrées** (dictionnaire `nom -> {TypeName, Value}`) — supprimerait toute concaténation de chaîne dans les filtres, et pourrait débloquer D13 (`Select`) |
|
||||
| `CommandTimeout` | timeout par requête, au lieu du timeout HTTP global de 30 s |
|
||||
| `QueryId` + `POST /QueryCancel` | annulation d'une requête longue |
|
||||
| `POST /QueryExecuteStream` | résultats en flux — piste sérieuse pour L3.1 (sorties volumineuses) |
|
||||
| `POST /QueryExecuteStream` | résultats en flux — piste long terme pour les sorties volumineuses, au-delà du bornage signalé de D24 |
|
||||
|
||||
Autres endpoints jamais utilisés, à évaluer : `QueryEvents`, `QueryCommands`,
|
||||
`QueryCorrelationEvents`, `QuerySnapshots` (event sourcing — utile en debug),
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
# 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.
|
||||
@@ -49,6 +49,16 @@ Examples:
|
||||
description: 'If true, return only parameters that have at least one warehouse override (default: false).',
|
||||
default: false,
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Maximum number of parameters returned per call (default: 50). The full unfiltered list is ~70,000 characters — raise this only if you really need everything at once.',
|
||||
default: 50,
|
||||
},
|
||||
offset: {
|
||||
type: 'number',
|
||||
description: 'Number of matching parameters to skip, for pagination (default: 0). Combine with limit to walk the full list.',
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -64,9 +74,18 @@ async function executeTool(name, args) {
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_PARAMS_LIMIT = 50;
|
||||
|
||||
async function getSystemParameters(args) {
|
||||
const { warehouse, param_class, search, only_overridden = false } = args || {};
|
||||
|
||||
// Bornes de pagination — valeurs invalides ramenées aux défauts, la
|
||||
// validation du wrapper (D23) ne contrôle que les noms de paramètres.
|
||||
const rawLimit = Number(args && args.limit);
|
||||
const limit = Number.isFinite(rawLimit) && rawLimit >= 1 ? Math.floor(rawLimit) : DEFAULT_PARAMS_LIMIT;
|
||||
const rawOffset = Number(args && args.offset);
|
||||
const offset = Number.isFinite(rawOffset) && rawOffset >= 0 ? Math.floor(rawOffset) : 0;
|
||||
|
||||
try {
|
||||
// Both entities are small (a few hundred rows max) — fetch fully and merge
|
||||
// client-side to avoid LINQ string-injection and null-field pitfalls.
|
||||
@@ -124,18 +143,33 @@ async function getSystemParameters(args) {
|
||||
|
||||
rows.sort((a, b) => String(a.code).localeCompare(String(b.code)));
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
// Pagination (L3.1) : sans elle la sortie sans filtre atteint ~70 000
|
||||
// caractères et se fait rejeter par les clients MCP. totalParameters est
|
||||
// le total correspondant aux filtres, AVANT pagination — le signal
|
||||
// truncated se vérifie donc depuis la réponse : offset + returned < total.
|
||||
const matched = rows.length;
|
||||
const page = rows.slice(offset, offset + limit);
|
||||
const truncated = offset + page.length < matched;
|
||||
|
||||
const payload = {
|
||||
success: true,
|
||||
warehouse: warehouse || '(none — effective value = default)',
|
||||
filters: { param_class: param_class || null, search: search || null, only_overridden },
|
||||
totalParameters: Array.isArray(parameters) ? parameters.length : 0,
|
||||
totalParameters: matched,
|
||||
totalOverrides: Array.isArray(paramValues) ? paramValues.length : 0,
|
||||
returned: rows.length,
|
||||
parameters: rows,
|
||||
}, null, 2),
|
||||
returned: page.length,
|
||||
offset,
|
||||
};
|
||||
if (truncated) {
|
||||
payload.truncated = true;
|
||||
payload.hint = `Showing parameters ${offset + 1}-${offset + page.length} of ${matched}. Call again with offset=${offset + page.length} for the next page, or narrow the result with param_class / search.`;
|
||||
}
|
||||
payload.parameters = page;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify(payload, null, 2),
|
||||
}],
|
||||
};
|
||||
} catch (err) {
|
||||
|
||||
+30
-13
@@ -182,22 +182,39 @@ async function searchLogs(args) {
|
||||
|
||||
const result = await logService.searchLogs(keyword, max_results, context_lines);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
// Garde-fou de taille (L3.1) : max_results borne le nombre de résultats,
|
||||
// pas le volume — les context_lines multiplient la taille (55 954 chars
|
||||
// mesurés avec les seuls défauts, rejetés par le client MCP). Au-delà du
|
||||
// plafond on écarte des résultats ENTIERS (jamais coupés au milieu de
|
||||
// leur contexte) et on le signale : truncated + omitted + hint.
|
||||
const cap = parseInt(process.env.MAX_LOG_SEARCH_CHARS) || 25000;
|
||||
|
||||
const buildText = (kept) => {
|
||||
const omitted = result.results.length - kept.length;
|
||||
const payload = {
|
||||
success: true,
|
||||
keyword: result.keyword,
|
||||
totalResults: result.totalResults,
|
||||
results: result.results,
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
returned: kept.length,
|
||||
};
|
||||
if (omitted > 0) {
|
||||
payload.truncated = true;
|
||||
payload.omitted = omitted;
|
||||
payload.hint = `Plafond de taille de réponse atteint (${cap} caractères) : ${kept.length} résultat(s) renvoyé(s) sur ${result.totalResults}, ${omitted} écarté(s). Affinez le keyword, réduisez context_lines ou baissez max_results.`;
|
||||
}
|
||||
payload.results = kept;
|
||||
return JSON.stringify(payload, null, 2);
|
||||
};
|
||||
|
||||
let kept = result.results.slice();
|
||||
let text = buildText(kept);
|
||||
while (text.length > cap && kept.length > 0) {
|
||||
kept.pop();
|
||||
text = buildText(kept);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text }],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user