660c62c32c
Cas reel du 25/08/2026 : une session Cowork cherchant des workflows CST_ sans
passer application="CustomApp" a conclu que l'AD n'en contenait aucun -- alors
que CustomApp en porte 153. Le parametre application existait bien (D26) et
etait documente ; ce qui manquait, c'est que RIEN dans la reponse ne disait
qu'on n'avait regarde qu'une application sur neuf. Un defaut silencieux se lit
comme une exhaustivite.
search_workflows et search_ad_elements rappellent desormais TOUJOURS
l'application effectivement interrogee (plus seulement quand le parametre a ete
passe), et ajoutent un hint quand la recherche revient vide.
Seuil a 0 resultat, pas "peu" : toute valeur non nulle produirait un hint
parasite sur une recherche legitimement etroite, et le mode d'echec observe est
bien le zero pris pour une absence.
Le hint nomme les autres applications depuis la liste allegee DEJA en cache ;
sans elle il reste generique et renvoie vers list_workflow_categories. Aucun
appel reseau n'est fait pour construire un hint -- ce serait exactement le
prechargement que D26 interdit.
Verifie en execution (protocole, LIMAGRAIN) :
search_workflows {"query":"CST_"} 404 chars
application: "EasyWMS", count: 0, hint nommant CustomApp et renvoyant
vers list_workflow_categories
search_workflows {"query":"CST_"} apres
list_workflow_categories 461 chars
meme hint, enrichi de la liste en cache : Common, Notifications, SmartUI,
WarehouseWebDesigner, GalileoFaults, CustomApp, User, AGV
search_workflows {"query":"CST_","application":"CustomApp"}
count: 44, application: "CustomApp" -- dont CST_SendRejectContainersToPK
search_workflows {"query":"stacker"} 14 192 -> 14 220 chars
count: 50 inchange, application: "EasyWMS" presente, aucun hint parasite
search_ad_elements Command "CST_" 457 chars
application: "EasyWMS", count: 0, meme hint
stderr : aucun fetch d'une application non demandee. Seules EasyWMS et
CustomApp sont chargees, chacune sur demande explicite.
Rebouclage complet apres modification des schemas : 23 outils, 6 resources,
les 23 noms de tools/list atteignent leur module (aucun "Unknown tool" ;
execute_command verifie statiquement, non appele car il ecrit dans le WMS),
D23 rejette toujours un parametre inconnu sur les quatre schemas modifies,
npm test 4/4 en code 0.
Lot 5 retire de la ROADMAP.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
291 lines
12 KiB
JavaScript
291 lines
12 KiB
JavaScript
/**
|
|
* Workflow Tools
|
|
* MCP tools for searching and retrieving workflow information via API
|
|
*/
|
|
|
|
const workflowService = require('../services/workflow-service');
|
|
|
|
// Taille par défaut d'une tranche du blob `data` de get_workflow_details.
|
|
// Ordre de grandeur cible de D24 (~20-25 000 caractères par réponse) : avec
|
|
// l'échappement JSON et les métadonnées, 20 000 caractères de blob tiennent
|
|
// sous ~23 000 caractères de réponse.
|
|
const DEFAULT_MAX_DATA_CHARS = 20000;
|
|
|
|
/**
|
|
* List available workflow tools
|
|
*/
|
|
function listTools() {
|
|
return [
|
|
{
|
|
name: 'search_workflows',
|
|
description: 'Search workflows by name. Returns matching workflows with metadata. Workflows are lazy-loaded from API on first request and cached for 1 hour, per application. Client-specific workflows (CST_* prefix) live in the "CustomApp" application — pass application: "CustomApp" to search them.',
|
|
inputSchema: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
properties: {
|
|
query: {
|
|
type: 'string',
|
|
description: 'Search query (searches in workflow name)',
|
|
},
|
|
category: {
|
|
type: 'string',
|
|
description: 'Filter by the applicationName field of the returned workflows (workflows have no category field). Since `application` selects which application is fetched, all its workflows share the same applicationName — prefer `application` to change scope; `category` only narrows within the fetched set.',
|
|
},
|
|
limit: {
|
|
type: 'number',
|
|
description: 'Maximum results to return (default: 50)',
|
|
default: 50,
|
|
},
|
|
application: {
|
|
type: 'string',
|
|
description: 'AD application whose workflows are searched (default: the active profile\'s application, usually EasyWMS). Client-specific workflows live in "CustomApp". Full list via list_workflow_categories.',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: 'get_workflow_details',
|
|
description: `Get full details of a specific workflow by ID or name.
|
|
The EasyBuilder definition (the \`data\` blob) is large — 71 512 characters for a StackerCrane workflow, 92 362 for CST_SendRejectContainersToPK — so it is returned as a VERBATIM WINDOW (max_data_chars / data_offset). Workflow metadata is always complete; only \`data\` is windowed. dataTotalChars always carries the full blob size, and concatenating the slices in offset order reproduces the definition byte for byte.`,
|
|
inputSchema: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
properties: {
|
|
workflow_id: {
|
|
type: 'string',
|
|
description: 'Workflow ID or exact name',
|
|
},
|
|
application: {
|
|
type: 'string',
|
|
description: 'AD application the workflow belongs to (default: the active profile\'s application, usually EasyWMS). Client-specific workflows (CST_*) live in "CustomApp".',
|
|
},
|
|
max_data_chars: {
|
|
type: 'number',
|
|
description: `Maximum number of characters of the \`data\` blob returned by this call (default: ${DEFAULT_MAX_DATA_CHARS}). The slice is verbatim — never summarised, reformatted or parsed. Pass 0 for metadata only.`,
|
|
default: DEFAULT_MAX_DATA_CHARS,
|
|
},
|
|
data_offset: {
|
|
type: 'number',
|
|
description: 'Character offset in the `data` blob where the returned slice starts (default: 0). When the response carries truncated: true, its hint gives the next offset to pass here.',
|
|
default: 0,
|
|
},
|
|
},
|
|
required: ['workflow_id'],
|
|
},
|
|
},
|
|
{
|
|
name: 'list_workflow_categories',
|
|
description: 'List the AD applications declared on the tenant (Application/GetAll) with their workflow counts where already loaded. Workflows have no category field — the application is the only grouping. Use the `application` parameter of the workflow/AD tools to query a specific one (e.g. "CustomApp" for client-specific CST_* workflows).',
|
|
inputSchema: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
properties: {
|
|
application: {
|
|
type: 'string',
|
|
description: 'Load and count the workflows of this application (default: the active profile\'s application). Other applications are listed without loading them.',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Execute workflow tool
|
|
*/
|
|
async function executeTool(name, args) {
|
|
try {
|
|
switch (name) {
|
|
case 'search_workflows':
|
|
return await searchWorkflows(args);
|
|
|
|
case 'get_workflow_details':
|
|
return await getWorkflowDetails(args);
|
|
|
|
case 'list_workflow_categories':
|
|
return await listWorkflowCategories(args);
|
|
|
|
default:
|
|
throw new Error(`Unknown workflow tool: ${name}`);
|
|
}
|
|
} catch (error) {
|
|
console.error(`[WorkflowTools] Error executing ${name}:`, error.message);
|
|
return {
|
|
content: [{
|
|
type: 'text',
|
|
text: JSON.stringify({
|
|
success: false,
|
|
error: error.message,
|
|
tool: name
|
|
}, null, 2)
|
|
}],
|
|
isError: true
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Tool: search_workflows
|
|
*/
|
|
async function searchWorkflows(args) {
|
|
const { query, category, limit = 50, application } = args;
|
|
|
|
console.error(`[WorkflowTools] Searching workflows: query="${query}", category="${category}", limit=${limit}, application=${application || '(profil)'}`);
|
|
|
|
const results = await workflowService.searchWorkflows(query, category, limit, application);
|
|
|
|
// L5.4 : l'application interrogée est TOUJOURS rappelée (pas seulement quand
|
|
// elle a été passée), et un résultat vide dit qu'une seule application sur
|
|
// neuf a été regardée — c'est ce silence qui avait fait conclure à tort à
|
|
// l'absence de workflows CST_.
|
|
const effectiveApplication = workflowService.resolveApplication(application);
|
|
const hint = results.length === 0
|
|
? workflowService.buildOtherApplicationsHint(effectiveApplication, 'workflow')
|
|
: null;
|
|
|
|
return {
|
|
content: [{
|
|
type: 'text',
|
|
text: JSON.stringify({
|
|
success: true,
|
|
application: effectiveApplication,
|
|
count: results.length,
|
|
...(hint ? { hint } : {}),
|
|
// Clés réelles de l'API AD (minuscules, cf. D5) : id, name, version,
|
|
// applicationName, commonInfo. Pas de code/category/description.
|
|
workflows: results.map(w => {
|
|
const commonInfo = w.commonInfo || w.CommonInfo || {};
|
|
return {
|
|
id: w.id || w.Id,
|
|
name: w.name || w.Name,
|
|
applicationName: w.applicationName || w.ApplicationName,
|
|
version: w.version || w.Version,
|
|
createdBy: commonInfo.createdBy,
|
|
createDate: commonInfo.createDate,
|
|
updateDate: commonInfo.updateDate
|
|
};
|
|
})
|
|
}, null, 2)
|
|
}]
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Garde de valeur des paramètres de fenêtre. Le wrapper D23 valide les noms de
|
|
* paramètres, pas les valeurs — la garde vit donc ici, avant tout appel réseau.
|
|
*/
|
|
function assertWindowValue(value, fallback, paramName) {
|
|
if (value == null) return fallback;
|
|
if (!Number.isInteger(value) || value < 0) {
|
|
const attendu = paramName === 'max_data_chars'
|
|
? `taille max de la tranche du blob data, défaut ${DEFAULT_MAX_DATA_CHARS}, 0 = métadonnées seules`
|
|
: 'offset de départ dans le blob data, défaut 0';
|
|
throw new Error(
|
|
`${paramName} invalide : ${JSON.stringify(value)}. Attendu : un entier >= 0 (${attendu}).`
|
|
);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
* Tool: get_workflow_details
|
|
*
|
|
* La définition EasyBuilder (blob `data`) fait à elle seule 71 512 caractères
|
|
* sur un StackerCrane et 92 362 sur CST_SendRejectContainersToPK : la réponse
|
|
* complète dépassait le seuil de rejet du client MCP (D24). On renvoie une
|
|
* TRANCHE VERBATIM du blob (découpe de chaîne, rien d'autre) : les métadonnées
|
|
* restent complètes, et concaténer les tranches dans l'ordre des offsets
|
|
* reconstitue la définition à l'octet près. Ne jamais résumer ni « parser » ce
|
|
* blob pour n'en renvoyer que des morceaux jugés utiles.
|
|
*/
|
|
async function getWorkflowDetails(args) {
|
|
const { workflow_id, application, max_data_chars, data_offset } = args;
|
|
|
|
const maxDataChars = assertWindowValue(max_data_chars, DEFAULT_MAX_DATA_CHARS, 'max_data_chars');
|
|
const dataOffset = assertWindowValue(data_offset, 0, 'data_offset');
|
|
|
|
console.error(`[WorkflowTools] Getting workflow details: ${workflow_id} (application: ${application || '(profil)'}, max_data_chars=${maxDataChars}, data_offset=${dataOffset})`);
|
|
|
|
const workflow = await workflowService.getWorkflowDetails(workflow_id, application);
|
|
|
|
const payload = { success: true, workflow };
|
|
|
|
// Seul un blob `data` textuel se fenêtre ; un workflow sans définition (ou
|
|
// d'une forme inattendue) sort inchangé.
|
|
if (typeof workflow?.data === 'string') {
|
|
const total = workflow.data.length;
|
|
const slice = workflow.data.slice(dataOffset, dataOffset + maxDataChars);
|
|
const nextOffset = dataOffset + slice.length;
|
|
|
|
payload.workflow = { ...workflow, data: slice };
|
|
// La taille totale est portée par TOUTE réponse : truncated se vérifie
|
|
// depuis la réponse elle-même (D24).
|
|
payload.dataTotalChars = total;
|
|
payload.dataOffset = dataOffset;
|
|
payload.returned = slice.length;
|
|
|
|
if (nextOffset < total) {
|
|
payload.truncated = true;
|
|
payload.hint =
|
|
`Blob \`data\` tronqué : ${slice.length} caractère(s) sur ${total} renvoyé(s) depuis l'offset ${dataOffset}. ` +
|
|
`Rappelez get_workflow_details avec les mêmes workflow_id/application et data_offset: ${nextOffset} pour la tranche ` +
|
|
`suivante (max_data_chars change la taille des tranches). Les tranches sont verbatim : les concaténer dans l'ordre ` +
|
|
`des offsets reconstitue la définition EasyBuilder à l'octet près.`;
|
|
}
|
|
}
|
|
|
|
return {
|
|
content: [{
|
|
type: 'text',
|
|
text: JSON.stringify(payload, null, 2)
|
|
}]
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Tool: list_workflow_categories
|
|
*/
|
|
async function listWorkflowCategories(args) {
|
|
const { application } = args || {};
|
|
|
|
console.error(`[WorkflowTools] Listing applications (workflow groupings), application=${application || '(profil)'}`);
|
|
|
|
// La liste vient d'Application/GetAll (9 applications sur le tenant mesuré),
|
|
// pas des applicationName du seul cache actif (D26). Seule l'application
|
|
// demandée (ou celle du profil) est chargée — pas de préchargement des
|
|
// autres (D10) : leurs comptes n'apparaissent que si déjà en cache.
|
|
const applications = await workflowService.fetchApplications();
|
|
const stats = await workflowService.getWorkflowStats(application);
|
|
const cacheStatus = workflowService.getCacheStatus();
|
|
|
|
const enriched = applications.map(a => ({
|
|
name: a.name,
|
|
version: a.version,
|
|
...(cacheStatus[a.name]
|
|
? { workflowCount: cacheStatus[a.name].count, cacheAge: cacheStatus[a.name].age }
|
|
: { workflowCount: null }),
|
|
}));
|
|
|
|
return {
|
|
content: [{
|
|
type: 'text',
|
|
text: JSON.stringify({
|
|
success: true,
|
|
note: 'Workflows have no category field in the AD API — the application is the only grouping. workflowCount is only known for applications already loaded (lazy loading); pass application to search_workflows/get_ad_elements to load one.',
|
|
totalApplications: applications.length,
|
|
applications: enriched,
|
|
loaded: {
|
|
application: stats.application,
|
|
totalWorkflows: stats.total,
|
|
cacheAge: stats.cacheAge
|
|
}
|
|
}, null, 2)
|
|
}]
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
listTools,
|
|
executeTool,
|
|
};
|