/** * 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); return { content: [{ type: 'text', text: JSON.stringify({ success: true, ...(application ? { application } : {}), count: results.length, // 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, };