version mise à jour par claude , à tester

This commit is contained in:
Arthur Ria
2026-05-20 09:38:07 +02:00
commit b59cbb3546
58 changed files with 24548 additions and 0 deletions
+83
View File
@@ -0,0 +1,83 @@
/**
* Constants for WMS MCP Server
*/
// WMS Entity Types available via Query API
// Based on queries api.php reference
const WMS_ENTITY_TYPES = [
// Master Data
'Products',
'Containers',
'Accounts',
'Suppliers',
'Kits',
'Aliases',
// Operations
'Tasks',
'Stocks',
'ProductLocations',
// Inbound
'InboundOrders',
'Receptions',
// Outbound
'OutboundOrders'
];
// Entity Categories for documentation
const ENTITY_CATEGORIES = {
'Master Data': ['Products', 'Containers', 'Accounts', 'Suppliers', 'Kits', 'Aliases'],
'Operations': ['Tasks', 'Stocks', 'ProductLocations'],
'Inbound': ['InboundOrders', 'Receptions'],
'Outbound': ['OutboundOrders']
};
// Common WMS Commands
// These can be used with call_command_api tool
const WMS_COMMANDS = {
'ProductRemove': 'Mecalux.ITSW.EasyWMS.Modules.MasterData.Contracts.Commands.ProductRemoveCommand',
'ProductUpdate': 'Mecalux.ITSW.EasyWMS.Modules.MasterData.Contracts.Commands.ProductUpdateCommand',
'ContainerCreate': 'Mecalux.ITSW.EasyWMS.Modules.MasterData.Contracts.Commands.ContainerCreateCommand',
'TaskCancel': 'Mecalux.ITSW.EasyWMS.Modules.Tasks.Contracts.Commands.TaskCancelCommand'
// Add more as needed
};
// MCP Resource URIs
const RESOURCE_URIS = {
WMS_ENTITIES: 'wms://entities',
ENTITY_SCHEMAS: 'wms://entity-schemas',
QUERY_EXAMPLES: 'wms://query-examples',
WORKFLOWS_OVERVIEW: 'workflows://overview',
WORKFLOWS_CATEGORIES: 'workflows://categories',
API_CATALOG: 'api://catalog',
LOGS_GUIDE: 'logs://guide'
};
// Log patterns for error detection
const LOG_ERROR_PATTERNS = [
'ERROR',
'EXCEPTION',
'FATAL',
'CRITICAL',
'FAILED',
'FAILURE',
'WARNING'
];
// Query limits
const QUERY_LIMITS = {
MAX_ROWS: parseInt(process.env.MAX_QUERY_ROWS) || 1000,
DEFAULT_LIMIT: 100,
TIMEOUT_MS: parseInt(process.env.QUERY_TIMEOUT) || 30000
};
module.exports = {
WMS_ENTITY_TYPES,
ENTITY_CATEGORIES,
WMS_COMMANDS,
RESOURCE_URIS,
LOG_ERROR_PATTERNS,
QUERY_LIMITS
};
+167
View File
@@ -0,0 +1,167 @@
/**
* Profile Manager
* Handles multiple WMS profiles (AD, LIMAGRAIN, ...) defined in .env.
*
* Shared settings (same for all profiles):
* - WMS_API_AUTH OAuth client Basic auth header
* - WMS_APPLICATION Application name (e.g. "EasyWMS")
* - WMS_API_PATH URL path to the ApplicationService API
* - WMS_TOKEN_PATH URL path to the OAuth token endpoint
* - WORKFLOW_API_PATH URL path to the AD API
*
* Per-profile settings (prefixed by profile name):
* - <PROFILE>_HOST Hostname or IP (e.g. "10.255.255.2", "p4swms.mss.mecalux.com")
* - <PROFILE>_USERNAME
* - <PROFILE>_PASSWORD
* - <PROFILE>_TENANT
* - <PROFILE>_SAAS "true" | "false" (default false). When true the WMS is
* cloud-hosted — local filesystem logs are not reachable,
* so log tools return an error instead of probing paths.
*
* Profile registry:
* - WMS_PROFILES Comma-separated list of profile names (e.g. "AD,LIMAGRAIN")
* - DEFAULT_WMS_PROFILE Profile activated at startup (optional)
*/
const DEFAULTS = {
apiPath: '/ApplicationService/api',
tokenPath: '/EasySTS/OAuth/Token',
workflowApiPath: '/AD/api',
application: 'EasyWMS',
protocol: 'https',
};
const listeners = [];
let profiles = {};
let currentProfileName = null;
/**
* Parse .env into an in-memory profile registry.
* Called once at startup; safe to call again after env changes (tests).
*/
function loadProfiles() {
profiles = {};
currentProfileName = null;
const names = (process.env.WMS_PROFILES || '')
.split(',')
.map(s => s.trim())
.filter(Boolean);
if (names.length === 0) {
console.error('[Profile] WARNING: WMS_PROFILES is empty — no profiles loaded');
return;
}
const shared = {
auth: process.env.WMS_API_AUTH,
application: process.env.WMS_APPLICATION || DEFAULTS.application,
apiPath: process.env.WMS_API_PATH || DEFAULTS.apiPath,
tokenPath: process.env.WMS_TOKEN_PATH || DEFAULTS.tokenPath,
workflowApiPath: process.env.WORKFLOW_API_PATH || DEFAULTS.workflowApiPath,
};
for (const name of names) {
const host = process.env[`${name}_HOST`];
const username = process.env[`${name}_USERNAME`];
const password = process.env[`${name}_PASSWORD`];
const tenant = process.env[`${name}_TENANT`];
const saasRaw = process.env[`${name}_SAAS`];
const saas = saasRaw != null && /^(true|1|yes)$/i.test(saasRaw.trim());
if (!host || !username || !password || !tenant) {
console.error(`[Profile] WARNING: profile "${name}" is incomplete (missing HOST/USERNAME/PASSWORD/TENANT) — skipped`);
continue;
}
const base = `${DEFAULTS.protocol}://${host}`;
profiles[name] = {
name,
host,
username,
password,
tenant,
saas,
auth: shared.auth,
application: shared.application,
apiBaseUrl: `${base}${shared.apiPath}`,
tokenUrl: `${base}${shared.tokenPath}`,
workflowApiBase: `${base}${shared.workflowApiPath}`,
};
}
const defaultName = process.env.DEFAULT_WMS_PROFILE;
if (defaultName && profiles[defaultName]) {
currentProfileName = defaultName;
console.error(`[Profile] Loaded ${Object.keys(profiles).length} profile(s). Active: ${currentProfileName}`);
} else if (defaultName) {
console.error(`[Profile] DEFAULT_WMS_PROFILE="${defaultName}" is not a valid profile — no profile active`);
} else {
console.error(`[Profile] Loaded ${Object.keys(profiles).length} profile(s). No default selected — Claude must call switch_wms_profile before any API call`);
}
}
function listProfiles() {
return Object.keys(profiles).sort();
}
function getCurrentName() {
return currentProfileName;
}
/**
* Return the active profile config. Throws a helpful error if none is selected,
* so tool responses tell Claude exactly what to do next.
*/
function getCurrent() {
if (!currentProfileName) {
const available = listProfiles();
const hint = available.length > 0
? `Available profiles: ${available.join(', ')}. Call switch_wms_profile with one of them.`
: 'No profiles configured in .env (WMS_PROFILES is empty).';
throw new Error(`No WMS profile selected. ${hint}`);
}
return profiles[currentProfileName];
}
/**
* Switch to a different profile. Notifies listeners (services) so they can
* reset token + invalidate caches tied to the previous profile.
*/
function switchTo(name) {
if (!profiles[name]) {
const available = listProfiles();
throw new Error(`Unknown profile "${name}". Available: ${available.join(', ') || '(none)'}`);
}
if (name === currentProfileName) {
return profiles[name];
}
const previous = currentProfileName;
currentProfileName = name;
console.error(`[Profile] Switched: ${previous || '(none)'}${name}`);
for (const listener of listeners) {
try {
listener({ previous, current: name });
} catch (err) {
console.error(`[Profile] Listener error: ${err.message}`);
}
}
return profiles[name];
}
/**
* Register a callback fired whenever the active profile changes.
* Used by services to invalidate caches / reset tokens.
*/
function onSwitch(listener) {
listeners.push(listener);
}
module.exports = {
loadProfiles,
listProfiles,
getCurrent,
getCurrentName,
switchTo,
onSwitch,
};
+283
View File
@@ -0,0 +1,283 @@
#!/usr/bin/env node
/**
* WMS MCP Server
* Main entry point for the Model Context Protocol server
*
* Architecture: 100% API-based (no direct database access)
* Data Access: All data retrieved via WMS REST APIs
*/
const path = require('path');
// Silence dotenv output to stdout (MCP requires stdout for JSON only)
const originalStdoutWrite = process.stdout.write;
process.stdout.write = process.stderr.write.bind(process.stderr);
require('dotenv').config({
path: path.join(__dirname, '..', '.env')
});
// Restore stdout
process.stdout.write = originalStdoutWrite;
// Load WMS profiles from .env before any service is instantiated.
// Services register onSwitch listeners at module load, so they must see
// profile-manager in its final state — but since loadProfiles() mutates
// the manager's internal state, it's safe to call it after requires too.
const profileManager = require('./config/profile-manager');
profileManager.loadProfiles();
const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
const {
CallToolRequestSchema,
ListToolsRequestSchema,
ListResourcesRequestSchema,
ReadResourceRequestSchema,
} = require('@modelcontextprotocol/sdk/types.js');
// Import resources
const wmsEntitiesResources = require('./resources/wms-entities.js');
const entitySchemasResources = require('./resources/entity-schemas.js');
const queryExamplesResources = require('./resources/query-examples.js');
const workflowsResources = require('./resources/workflows.js');
const apisResources = require('./resources/apis.js');
const logsResources = require('./resources/logs.js');
// Import tools
const workflowTools = require('./tools/workflow-tools.js');
const wmsQueryTools = require('./tools/wms-query-tools.js');
const apiTools = require('./tools/api-tools.js');
const logTools = require('./tools/log-tools.js');
const adTools = require('./tools/ad-tools.js');
const metadataTools = require('./tools/metadata-tools.js');
const configTools = require('./tools/config-tools.js');
const profileTools = require('./tools/profile-tools.js');
// Create MCP Server
const server = new Server(
{
name: 'wms-mcp-server',
version: '1.0.0',
},
{
capabilities: {
resources: {},
tools: {},
},
}
);
/**
* Handler: resources/list
* List all available resources
*/
server.setRequestHandler(ListResourcesRequestSchema, async () => {
const allResources = [
...wmsEntitiesResources.listResources(),
...entitySchemasResources.listResources(),
...queryExamplesResources.listResources(),
...workflowsResources.listResources(),
...apisResources.listResources(),
...logsResources.listResources(),
];
return {
resources: allResources,
};
});
/**
* Handler: resources/read
* Read a specific resource
*/
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const { uri } = request.params;
try {
// Route to the appropriate handler based on URI
if (uri.startsWith('wms://entities')) {
return await wmsEntitiesResources.readResource(uri);
} else if (uri.startsWith('wms://entity-schemas')) {
return await entitySchemasResources.readResource(uri);
} else if (uri.startsWith('wms://query-examples')) {
return await queryExamplesResources.readResource(uri);
} else if (uri.startsWith('workflows://')) {
return await workflowsResources.readResource(uri);
} else if (uri.startsWith('api://')) {
return await apisResources.readResource(uri);
} else if (uri.startsWith('logs://')) {
return await logsResources.readResource(uri);
} else {
throw new Error(`Unknown resource URI: ${uri}`);
}
} catch (error) {
console.error(`[Server] Error reading resource ${uri}:`, error.message);
return {
contents: [
{
uri,
mimeType: 'text/plain',
text: `Error reading resource: ${error.message}`,
},
],
};
}
});
/**
* Handler: tools/list
* List all available tools
*/
server.setRequestHandler(ListToolsRequestSchema, async () => {
const allTools = [
...workflowTools.listTools(),
...wmsQueryTools.listTools(),
...apiTools.listTools(),
...logTools.listTools(),
...adTools.listTools(),
...metadataTools.listTools(),
...configTools.listTools(),
...profileTools.listTools(),
];
return {
tools: allTools,
};
});
/**
* Handler: tools/call
* Execute a specific tool
*/
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
console.error(`[Server] Executing tool: ${name}`);
// Route to the appropriate handler based on tool name
if (name.startsWith('search_workflows') ||
name.startsWith('get_workflow_') ||
name.startsWith('list_workflow_')) {
return await workflowTools.executeTool(name, args);
} else if (name.startsWith('query_wms_') ||
name.startsWith('count_wms_') ||
name.startsWith('get_entity_') ||
name.startsWith('search_wms_')) {
return await wmsQueryTools.executeTool(name, args);
} else if (name.startsWith('call_query_api') ||
name.startsWith('execute_command')) {
return await apiTools.executeTool(name, args);
} else if (name.includes('_logs')) {
return await logTools.executeTool(name, args);
} else if (name.startsWith('get_application_') ||
name.startsWith('get_ad_') ||
name.startsWith('search_ad_') ||
name.startsWith('list_ad_')) {
return await adTools.executeTool(name, args);
} else if (name === 'get_entity_metadata' || name === 'generic_search') {
return await metadataTools.executeTool(name, args);
} else if (name === 'get_system_parameters') {
return await configTools.executeTool(name, args);
} else if (name === 'list_wms_profiles' ||
name === 'get_current_wms_profile' ||
name === 'switch_wms_profile') {
return await profileTools.executeTool(name, args);
} else {
throw new Error(`Unknown tool: ${name}`);
}
} catch (error) {
console.error(`[Server] Error executing tool ${name}:`, error.message);
return {
content: [
{
type: 'text',
text: JSON.stringify(
{
success: false,
error: error.message,
tool: name,
},
null,
2
),
},
],
isError: true,
};
}
});
/**
* Start the server
*/
async function main() {
try {
console.error('[Server] Starting WMS MCP Server...');
console.error('[Server] Architecture: 100% API-based (no direct database access)');
// Validate shared environment variables
const requiredSharedEnvVars = ['WMS_API_AUTH', 'WMS_PROFILES'];
const missingShared = requiredSharedEnvVars.filter(v => !process.env[v]);
if (missingShared.length > 0) {
console.error('[Server] WARNING: Missing shared env vars:', missingShared.join(', '));
}
// Validate that at least one profile is loaded
const profiles = profileManager.listProfiles();
if (profiles.length === 0) {
console.error('[Server] WARNING: No WMS profile loaded. Define WMS_PROFILES + <NAME>_HOST/USERNAME/PASSWORD/TENANT in .env');
} else {
console.error(`[Server] Profiles available: ${profiles.join(', ')}`);
const current = profileManager.getCurrentName();
if (current) {
console.error(`[Server] Active profile: ${current}`);
} else {
console.error('[Server] No active profile — Claude must call switch_wms_profile before any API call');
}
}
// Create stdio transport
const transport = new StdioServerTransport();
// Connect server
await server.connect(transport);
console.error('[Server] WMS MCP Server running on stdio');
console.error('[Server] Server name: wms-mcp-server');
console.error('[Server] Version: 1.0.0');
console.error('[Server] Ready to accept requests from Claude Desktop');
} catch (error) {
console.error('[Server] Fatal error starting server:', error.message);
process.exit(1);
}
}
/**
* Handle graceful shutdown
*/
process.on('SIGINT', async () => {
console.error('\n[Server] Shutting down WMS MCP Server (SIGINT)...');
process.exit(0);
});
process.on('SIGTERM', async () => {
console.error('\n[Server] Shutting down WMS MCP Server (SIGTERM)...');
process.exit(0);
});
// Handle uncaught errors
process.on('uncaughtException', (error) => {
console.error('[Server] Uncaught exception:', error.message);
console.error(error.stack);
});
process.on('unhandledRejection', (reason, promise) => {
console.error('[Server] Unhandled rejection at:', promise);
console.error('[Server] Reason:', reason);
});
// Start the server
main();
+266
View File
@@ -0,0 +1,266 @@
/**
* API Resources
* Provides catalog of WMS APIs available
*/
function listResources() {
return [
{
uri: 'api://catalog',
name: 'API Catalog',
description: 'Catalog of WMS APIs available with parameters and documentation',
mimeType: 'text/markdown',
},
];
}
async function readResource(uri) {
let content;
switch (uri) {
case 'api://catalog':
content = getAPICatalog();
break;
default:
throw new Error(`Unknown API resource: ${uri}`);
}
return {
contents: [{
uri,
mimeType: 'text/markdown',
text: content,
}],
};
}
function getAPICatalog() {
return `# WMS API Catalog
## Overview
The WMS provides several REST APIs for querying and modifying data.
**Base URL:** \`${process.env.WMS_API_BASE_URL || 'https://10.255.255.2/ApplicationService/api'}\`
**Authentication:** OAuth 2.0 Bearer Token (automatic)
---
## Query API
Execute LINQ queries against WMS entities.
**Endpoint:** \`/api/QueryExecute\`
**Method:** POST
**Authentication:** Required
### Request Format
\`\`\`json
{
"Application": "EasyWMS",
"QueryType": 1,
"Expression": "Context.{EntityType}.Select(z => z)"
}
\`\`\`
### Supported Entity Types
| Entity Type | Description |
|-------------|-------------|
| Products | Product references and SKUs |
| Containers | Pallets, boxes, and container types |
| Stocks | Available inventory by location |
| ProductLocations | Product placement in warehouse |
| Tasks | WMS tasks (picks, puts, moves, etc.) |
| Accounts | Customer accounts |
| Suppliers | Supplier information |
| Kits | Product kits and bundles |
| Aliases | Product aliases and alternative codes |
| InboundOrders | Inbound/receiving orders |
| Receptions | Actual receptions |
| OutboundOrders | Outbound/shipping orders |
### Example Queries
\`\`\`
# Get all products (limited)
Context.Products.Take(100).Select(z => z)
# Get specific fields
Context.Products.Select(z => new { z.Id, z.Code, z.Name })
# Filter and select
Context.Tasks.Where(z => z.Status == "Pending").Take(50).Select(z => z)
\`\`\`
### MCP Tool
Use \`call_query_api\` tool to execute queries.
---
## Command API
Execute commands to modify WMS data.
**Endpoint:** \`/api/CommandExecute\`
**Method:** POST
**Authentication:** Required
### Request Format
\`\`\`json
[
{
"Name": "CommandName, Mecalux.ITSW.EasyWMS.Modules.Contracts",
"Properties": {
"PropertyName": "value"
}
}
]
\`\`\`
### Common Commands
| Command | Description |
|---------|-------------|
| ProductRemoveCommand | Remove a product |
| ProductUpdateCommand | Update product information |
| ContainerCreateCommand | Create a new container |
| TaskCancelCommand | Cancel a task |
| InboundOrderCancelCommandV2 | Cancel an inbound order |
| OutboundOrderCancelCommand | Cancel an outbound order |
### Example Command
\`\`\`json
[
{
"Name": "Mecalux.ITSW.EasyWMS.Modules.MasterData.Contracts.Commands.ProductRemoveCommand, Mecalux.ITSW.EasyWMS.Modules.Contracts",
"Properties": {
"Id": "product-guid-here"
}
}
]
\`\`\`
### MCP Tool
Use \`execute_command\` tool to execute commands.
**⚠️ WARNING:** Commands modify data. Use with caution.
---
## Workflow API
Retrieve workflow definitions by application.
**Endpoint:** \`/AD/api/Workflow/GetByApplication\`
**Method:** POST
**Authentication:** Required
### Request Format
\`\`\`json
["EasyWMS", "AD", 5000, 0]
\`\`\`
Parameters:
1. Application name (e.g., "EasyWMS")
2. Tenant code (e.g., "AD")
3. Page size (e.g., 5000)
4. Offset (e.g., 0 for first page)
### Response
Array of workflow objects with:
- Id, Code, Name
- Category, Description
- Version, Status
- Created, Modified
- Definition (JSON)
### MCP Tools
Use workflow tools to interact with workflows:
- \`search_workflows\` - Search by name, code, description
- \`get_workflow_details\` - Get full workflow definition
- \`list_workflow_categories\` - List all categories
---
## Authentication
All APIs use OAuth 2.0 authentication.
**Token Endpoint:** \`/EasySTS/OAuth/Token\`
**Grant Types:** password, refresh_token
### Token Management
- Tokens expire after ~1200 seconds
- Automatic refresh when < 1000 seconds remaining
- Credentials configured in .env file
The MCP server handles authentication automatically.
---
## Error Handling
### HTTP Status Codes
- \`200\` - Success
- \`400\` - Bad request (invalid query/command)
- \`401\` - Unauthorized (token expired or invalid)
- \`403\` - Forbidden (insufficient permissions)
- \`500\` - Internal server error
### Error Response Format
\`\`\`json
{
"error": "Error message",
"details": "Detailed error information"
}
\`\`\`
---
## Rate Limits and Constraints
- **Query Limit:** Maximum 1000 rows per query (configurable)
- **Query Timeout:** 30 seconds (configurable)
- **Workflow Cache:** 1 hour TTL (configurable)
---
## Configuration
API settings are configured via environment variables:
\`\`\`env
WMS_API_BASE_URL=https://10.255.255.2/ApplicationService/api
WMS_API_TOKEN_URL=https://10.255.255.2/EasySTS/OAuth/Token
WMS_API_TENANT=AD
WMS_API_USERNAME=your-username
WMS_API_PASSWORD=your-password
WORKFLOW_API_BASE=https://10.255.255.2/AD/api
WORKFLOW_PAGE_SIZE=5000
MAX_QUERY_ROWS=1000
QUERY_TIMEOUT=30000
\`\`\`
---
**Note:** Use MCP tools to interact with these APIs. Direct API calls require proper authentication handling.
`;
}
module.exports = {
listResources,
readResource,
};
+193
View File
@@ -0,0 +1,193 @@
const fs = require('fs').promises;
const path = require('path');
/**
* Resources MCP pour la documentation
* Permet à Claude d'accéder à la documentation structurée en fichiers Markdown
*/
// Chemin vers le dossier de documentation
const DOCS_PATH = process.env.DOCS_PATH || path.join(__dirname, '..', '..', 'docs');
/**
* Scanne récursivement un dossier pour trouver tous les fichiers .md
* @param {string} dir - Dossier à scanner
* @param {string} baseDir - Dossier de base pour les chemins relatifs
* @returns {Promise<Array>} - Liste des fichiers .md
*/
async function scanMarkdownFiles(dir, baseDir = dir) {
let files = [];
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
// Récursion dans les sous-dossiers
const subFiles = await scanMarkdownFiles(fullPath, baseDir);
files = files.concat(subFiles);
} else if (entry.isFile() && entry.name.endsWith('.md')) {
// Fichier Markdown trouvé
const relativePath = path.relative(baseDir, fullPath);
files.push({
name: entry.name,
path: fullPath,
relativePath: relativePath.replace(/\\/g, '/'), // Normaliser les slashes
uri: `docs://${relativePath.replace(/\\/g, '/')}`,
});
}
}
} catch (err) {
// Dossier n'existe pas ou erreur de lecture
console.error(`Error scanning directory ${dir}:`, err.message);
}
return files;
}
/**
* Liste les resources disponibles pour la documentation
* @returns {Promise<Array>} - Liste des resources
*/
async function listResources() {
try {
// Scanner les fichiers Markdown
const mdFiles = await scanMarkdownFiles(DOCS_PATH);
const resources = [
{
uri: 'docs://index',
name: 'Documentation Index',
description: 'Sommaire de toute la documentation disponible',
mimeType: 'text/markdown',
},
];
// Ajouter chaque fichier .md comme resource
mdFiles.forEach((file) => {
resources.push({
uri: file.uri,
name: file.name.replace('.md', ''),
description: `Documentation: ${file.relativePath}`,
mimeType: 'text/markdown',
});
});
return resources;
} catch (err) {
console.error('Error listing documentation resources:', err);
return [
{
uri: 'docs://index',
name: 'Documentation Index',
description: 'Sommaire de toute la documentation disponible',
mimeType: 'text/markdown',
},
];
}
}
/**
* Génère un index/sommaire de la documentation
* @returns {Promise<string>} - Markdown avec le sommaire
*/
async function generateIndex() {
try {
const mdFiles = await scanMarkdownFiles(DOCS_PATH);
if (mdFiles.length === 0) {
return `# Documentation\n\n*Aucun fichier de documentation trouvé dans \`${DOCS_PATH}\`*\n\n` +
`Pour ajouter de la documentation :\n` +
`1. Créez un dossier \`docs\` à la racine du projet\n` +
`2. Ajoutez vos fichiers .md (organisation libre avec sous-dossiers)\n` +
`3. Redémarrez le serveur MCP\n`;
}
let markdown = '# Documentation WMS\n\n';
markdown += `**${mdFiles.length} fichiers de documentation disponibles**\n\n`;
markdown += `📁 Emplacement : \`${DOCS_PATH}\`\n\n`;
// Grouper par dossier
const grouped = {};
mdFiles.forEach((file) => {
const dir = path.dirname(file.relativePath);
const folder = dir === '.' ? '📄 Racine' : `📁 ${dir}`;
if (!grouped[folder]) {
grouped[folder] = [];
}
grouped[folder].push(file);
});
// Générer le sommaire
markdown += '## Sommaire\n\n';
Object.keys(grouped).sort().forEach((folder) => {
markdown += `### ${folder}\n\n`;
grouped[folder].forEach((file) => {
markdown += `- **${file.name.replace('.md', '')}** - \`${file.uri}\`\n`;
});
markdown += '\n';
});
markdown += '---\n\n';
markdown += '*Pour lire un fichier, demandez à Claude de lire la resource correspondante (par exemple: "Lis la documentation X")*\n';
return markdown;
} catch (err) {
return `# Erreur\n\nImpossible de générer l'index de documentation: ${err.message}`;
}
}
/**
* Lit un fichier de documentation
* @param {string} relativePath - Chemin relatif du fichier
* @returns {Promise<string>} - Contenu Markdown du fichier
*/
async function readDocFile(relativePath) {
try {
const filePath = path.join(DOCS_PATH, relativePath);
const content = await fs.readFile(filePath, 'utf8');
return content;
} catch (err) {
return `# Erreur\n\nImpossible de lire le fichier \`${relativePath}\`: ${err.message}`;
}
}
/**
* Lit une resource documentation selon son URI
* @param {string} uri - URI de la resource (format: docs://path/to/file.md)
* @returns {Promise<Object>} - Contenu de la resource
*/
async function readResource(uri) {
let content;
if (uri === 'docs://index') {
content = await generateIndex();
} else if (uri.startsWith('docs://')) {
// Extraire le chemin relatif de l'URI
const relativePath = uri.replace('docs://', '');
content = await readDocFile(relativePath);
} else {
throw new Error(`Unknown documentation resource: ${uri}`);
}
return {
contents: [
{
uri,
mimeType: 'text/markdown',
text: content,
},
],
};
}
module.exports = {
listResources,
readResource,
};
+156
View File
@@ -0,0 +1,156 @@
/**
* Entity Schema Resources
* Provides schema information for WMS entity types
*/
function listResources() {
return [
{
uri: 'wms://entity-schemas',
name: 'Entity Schemas',
description: 'Schema information and common fields for WMS entities',
mimeType: 'text/markdown',
},
];
}
async function readResource(uri) {
let content;
switch (uri) {
case 'wms://entity-schemas':
content = getEntitySchemas();
break;
default:
throw new Error(`Unknown entity schema resource: ${uri}`);
}
return {
contents: [{
uri,
mimeType: 'text/markdown',
text: content,
}],
};
}
function getEntitySchemas() {
return `# WMS Entity Schemas
## Overview
Entity schemas define the structure and available fields for each entity type.
Use the \`get_entity_schema\` tool to retrieve the actual schema from the API by querying one entity.
## Common Field Patterns
Most WMS entities follow these patterns:
### Standard Fields
- \`Id\` - Unique identifier (GUID or integer)
- \`Code\` - Business code/reference
- \`Name\` - Display name
- \`Description\` - Detailed description
- \`Created\` - Creation timestamp
- \`Modified\` - Last modification timestamp
- \`Status\` - Entity status
### Relationship Fields
- \`{Entity}Id\` - Foreign key to related entity (e.g., ProductId, AccountId)
- \`{Entity}Code\` - Related entity code
- \`{Entity}Name\` - Related entity name
## Entity-Specific Schemas
### Products
Common fields:
- Id, Code, Name, Description
- Category, Family, SubFamily
- Weight, Volume, Height, Width, Length
- UnitOfMeasure, BaseUnit
- Barcode, AlternativeCodes
- Active, Blocked
### Containers
Common fields:
- Id, Code, Name, Type
- Status, Location, Zone
- Weight, Tare, MaxWeight
- ProductId, Quantity
- Created, Modified
### Tasks
Common fields:
- Id, Code, Type, Status
- Priority, Sequence
- SourceLocation, TargetLocation
- ProductId, Quantity
- AssignedUser, AssignedDevice
- StartedAt, CompletedAt
### Stocks
Common fields:
- ProductId, ProductCode, ProductName
- LocationId, LocationCode
- Quantity, AvailableQuantity, ReservedQuantity
- UnitOfMeasure
- LotNumber, SerialNumber
- ExpirationDate
### InboundOrders / OutboundOrders
Common fields:
- Id, Code, ExternalCode
- Type, Status, Priority
- AccountId, AccountCode, AccountName
- SupplierId, SupplierCode (inbound only)
- ExpectedDate, PlannedDate, ActualDate
- TotalLines, TotalQuantity
- Created, Modified
## Retrieving Schemas
Use the \`get_entity_schema\` tool to dynamically retrieve the schema:
\`\`\`
# Get Products schema
get_entity_schema(entity_type="Products")
# Get Tasks schema
get_entity_schema(entity_type="Tasks")
\`\`\`
This will query one entity from the API and return all available fields with sample values.
## LINQ Field Selection
You can select specific fields using LINQ expressions:
\`\`\`
# Select specific fields
query_wms_entities(
entity_type="Products",
select_expression="z => new { z.Id, z.Code, z.Name, z.Category }"
)
# Nested object creation
query_wms_entities(
entity_type="Tasks",
select_expression="z => new {
Task = z.Code,
Product = z.ProductName,
Status = z.Status
}"
)
\`\`\`
---
**Note:** Field availability may vary depending on WMS configuration and version.
`;
}
module.exports = {
listResources,
readResource,
};
+295
View File
@@ -0,0 +1,295 @@
/**
* Logs Resources
* Provides guide for WMS log files
*/
function listResources() {
return [
{
uri: 'logs://guide',
name: 'Logs Guide',
description: 'Guide for WMS log files: formats, locations, common error patterns',
mimeType: 'text/markdown',
},
];
}
async function readResource(uri) {
let content;
switch (uri) {
case 'logs://guide':
content = getLogsGuide();
break;
default:
throw new Error(`Unknown logs resource: ${uri}`);
}
return {
contents: [{
uri,
mimeType: 'text/markdown',
text: content,
}],
};
}
function getLogsGuide() {
return `# WMS Logs Guide
## Log File Locations
**Primary Logs:** \`${process.env.LOGS_PATH || 'C:\\inetpub\\logs\\LogFiles\\Mecalux'}\`
Log files are typically named with date patterns:
- \`application_YYYYMMDD.log\`
- \`error_YYYYMMDD.log\`
- \`api_YYYYMMDD.log\`
---
## Log File Format
WMS logs typically follow this format:
\`\`\`
[YYYY-MM-DD HH:MM:SS.fff] [LEVEL] [Module] Message
Additional context or stack trace (if applicable)
\`\`\`
### Example Log Entries
\`\`\`
[2024-01-15 10:23:45.123] [INFO] [TaskService] Task TASK-001 created successfully
[2024-01-15 10:23:46.456] [ERROR] [StockService] Insufficient stock for product PROD-123
[2024-01-15 10:23:47.789] [WARN] [OrderService] Order ORD-456 delayed - expected date passed
\`\`\`
---
## Log Levels
| Level | Description | Severity |
|-------|-------------|----------|
| DEBUG | Detailed diagnostic information | Low |
| INFO | General informational messages | Low |
| WARN | Warning messages for potential issues | Medium |
| ERROR | Error messages for failures | High |
| FATAL | Critical errors causing system failure | Critical |
---
## Common Error Patterns
### Database Errors
\`\`\`
ORA-00001: unique constraint violated
ORA-01017: invalid username/password
ORA-12170: connection timeout
ORA-00060: deadlock detected
Connection timeout
Constraint violation
Foreign key violation
\`\`\`
### WMS Business Errors
\`\`\`
Insufficient stock
Stock not found
Product not found
Location occupied
Location not available
Task already completed
Order already processed
Invalid product code
Invalid container code
\`\`\`
### Integration Errors
\`\`\`
API call failed
Authentication failed
Token expired
Request timeout
JSON parse error
XML parse error
Connection refused
Service unavailable
\`\`\`
### System Errors
\`\`\`
Out of memory
OutOfMemoryException
StackOverflowException
File not found
Access denied
Permission denied
Disk full
Network unreachable
\`\`\`
---
## MCP Tools for Logs
### read_recent_logs
Read the most recent log entries from a log file.
**Parameters:**
- \`count\` - Number of lines to read (default: 100)
- \`log_file\` - Specific log file name (optional, uses most recent if not specified)
**Example:**
\`\`\`
read_recent_logs(count=200)
read_recent_logs(count=50, log_file="error_20240115.log")
\`\`\`
### search_logs
Search for a keyword across all log files with context.
**Parameters:**
- \`keyword\` - Search term (e.g., "ERROR", "PROD-123", "timeout")
- \`max_results\` - Maximum results to return (default: 50)
- \`context_lines\` - Lines of context before/after match (default: 2)
**Example:**
\`\`\`
search_logs(keyword="ERROR", max_results=100)
search_logs(keyword="PROD-123", context_lines=5)
search_logs(keyword="timeout", max_results=20)
\`\`\`
---
## Debugging Workflow
### 1. Check Recent Activity
Start by reading recent logs to understand current system state:
\`\`\`
read_recent_logs(count=200)
\`\`\`
### 2. Search for Errors
Look for error patterns:
\`\`\`
search_logs(keyword="ERROR", max_results=50)
search_logs(keyword="EXCEPTION", max_results=50)
search_logs(keyword="FAILED", max_results=50)
\`\`\`
### 3. Search for Specific Entity
Find references to specific orders, products, or tasks:
\`\`\`
search_logs(keyword="ORDER-123", context_lines=5)
search_logs(keyword="PROD-456", context_lines=5)
\`\`\`
### 4. Correlate with API/Database
Once you find relevant log entries:
- Use \`query_wms_entities\` to check current entity state
- Use \`search_workflows\` to find related workflows
- Use \`call_query_api\` to verify data consistency
---
## Log Analysis Tips
### Time-Based Analysis
Log timestamps help correlate events:
- Look for errors occurring at the same time
- Check for patterns (e.g., errors every hour, at specific times)
- Correlate with known system events (deployments, restarts)
### Frequency Analysis
- How often does this error occur?
- Is it increasing or decreasing over time?
- Does it happen at specific intervals?
### Context Analysis
Always request context lines when searching:
- See what happened before the error
- See what happened after the error
- Understand the execution flow
### Pattern Recognition
Look for common patterns:
- Same error message repeatedly
- Same product/order/task in multiple errors
- Same module or service causing issues
- Cascading failures (one error leading to others)
---
## Common Debugging Scenarios
### Scenario 1: Order Not Processing
\`\`\`
1. search_logs(keyword="ORDER-123", context_lines=5)
2. query_wms_entities(entity_type="OutboundOrders", filter='z.Code == "ORDER-123"')
3. Check order status and error messages in both logs and database
\`\`\`
### Scenario 2: Stock Discrepancy
\`\`\`
1. search_logs(keyword="PROD-456", context_lines=3)
2. query_wms_entities(entity_type="Stocks", filter='z.ProductCode == "PROD-456"')
3. Compare expected vs actual stock levels
4. Check for stock movement tasks
\`\`\`
### Scenario 3: Task Failures
\`\`\`
1. search_logs(keyword="TASK-789", context_lines=5)
2. query_wms_entities(entity_type="Tasks", filter='z.Code == "TASK-789"')
3. Check task status, error message, and assigned user/device
\`\`\`
### Scenario 4: Performance Issues
\`\`\`
1. search_logs(keyword="timeout", max_results=100)
2. search_logs(keyword="slow", max_results=100)
3. Look for patterns in timeouts (specific operations, times, modules)
\`\`\`
---
## Log Retention
- Log files are typically rotated daily
- Older logs may be compressed or archived
- Check with system administrator for retention policy
---
**Note:** Use log tools in combination with WMS entity queries for complete debugging picture.
`;
}
module.exports = {
listResources,
readResource,
};
+266
View File
@@ -0,0 +1,266 @@
/**
* Query Examples Resources
* Provides example LINQ queries and diagnostic recipes for WMS entities.
*/
function listResources() {
return [
{
uri: 'wms://query-examples',
name: 'Query Examples',
description: 'LINQ query examples and diagnostic recipes for WMS entities',
mimeType: 'text/markdown',
},
];
}
async function readResource(uri) {
let content;
switch (uri) {
case 'wms://query-examples':
content = getQueryExamples();
break;
default:
throw new Error(`Unknown query examples resource: ${uri}`);
}
return {
contents: [{
uri,
mimeType: 'text/markdown',
text: content,
}],
};
}
function getQueryExamples() {
return `# WMS LINQ Query Examples & Diagnostic Recipes
> **Reading model** — \`query_wms_entities\` / \`count_wms_entities\` use \`QueryExecute\`
> (QueryType=Reading). Status/enum fields are **strings** (enum names), never integers.
> Always verify enum values via \`docs://entities/\` or \`get_entity_metadata\` before filtering.
---
## Diagnostic Recipes
Recipes below were validated against a live WMS. Use them as-is, substituting the
\`X\` placeholders.
### OS incomplètes par classe d'expédition
Compter les ordres de sortie incomplets, actifs, d'une classe donnée
(\`OutboundClassCode\`). Fields verified on the \`OutboundOrder\` Reading entity:
\`OutboundClassCode\` (string), \`IncompleteOrder\` (bool), \`IsActive\` (bool).
\`\`\`
# Toutes classes confondues
count_wms_entities(
entity_type="OutboundOrders",
filter='z.IncompleteOrder == true && z.IsActive == true'
)
# Pour une classe précise
count_wms_entities(
entity_type="OutboundOrders",
filter='z.OutboundClassCode == "SHIPPING_GROUP_01" && z.IncompleteOrder == true && z.IsActive == true'
)
# Lister le détail des OS concernées
query_wms_entities(
entity_type="OutboundOrders",
filter='z.OutboundClassCode == "SHIPPING_GROUP_01" && z.IncompleteOrder == true && z.IsActive == true',
limit=200
)
\`\`\`
### Containers bloquants sur un emplacement
Identifier les conteneurs qui retiennent un emplacement parce qu'ils ont des tâches
en attente. Fields verified on the \`Container\` Reading entity:
\`LocationCode\` (string), \`NumContainerPendingTasks\` (long).
\`\`\`
# Combien de containers ont des tâches en attente sur l'emplacement
count_wms_entities(
entity_type="Containers",
filter='z.LocationCode == "QUAI_EXP_01" && z.NumContainerPendingTasks > 0'
)
# Lister ces containers (renvoie toutes les colonnes du container)
query_wms_entities(
entity_type="Containers",
filter='z.LocationCode == "QUAI_EXP_01" && z.NumContainerPendingTasks > 0',
limit=200
)
# Vue globale : tous les containers bloquants du WMS
count_wms_entities(
entity_type="Containers",
filter='z.NumContainerPendingTasks > 0'
)
\`\`\`
### Paramètres système d'un entrepôt
Pour lire les paramètres de configuration WMS et leurs valeurs par entrepôt,
utiliser l'outil dédié \`get_system_parameters\` plutôt qu'une requête LINQ :
\`\`\`
get_system_parameters(warehouse="DOMBASLE") # tous les paramètres
get_system_parameters(warehouse="DOMBASLE", param_class="Shipping")
get_system_parameters(search="CROSSDOCK")
\`\`\`
### Modèles d'expédition — suivi des exécutions
> ⚠️ L'API ne conserve que la **dernière** exécution de chaque modèle d'expédition
> (\`ShipmentTemplate.LastExecuteDate\`). L'historique complet des exécutions n'existe
> que dans les logs serveur \`ApplyShipmentTemplates\`, non exposés par ce MCP.
Fields verified on the \`ShipmentTemplate\` Reading entity: \`Code\`, \`Status\`,
\`IsEnabled\`, \`IsActive\`, \`LastExecuteDate\`, \`WarehouseCode\`, \`Priority\`,
\`DirectiveCode\`.
\`\`\`
# Tous les modèles d'un entrepôt + leur dernière exécution et leur statut
query_wms_entities(
entity_type="ShipmentTemplates",
filter='z.WarehouseCode == "DOMBASLE"',
limit=200
)
# Modèles activés mais jamais exécutés (LastExecuteDate null)
query_wms_entities(
entity_type="ShipmentTemplates",
filter='z.IsEnabled == true && z.LastExecuteDate == null',
limit=200
)
# Combien de modèles exécutés au moins une fois
count_wms_entities(
entity_type="ShipmentTemplates",
filter='z.LastExecuteDate != null'
)
# Modèles exécutés depuis une date (littéral DateTime obligatoire — voir Date filters)
count_wms_entities(
entity_type="ShipmentTemplates",
filter='z.LastExecuteDate >= new DateTime(2025, 1, 1)'
)
\`\`\`
---
## Counting (\`count_wms_entities\`)
Always prefer \`count_wms_entities\` for any "combien" / "how many" question — it uses
\`QueryScalarExecute\` and never materialises rows.
\`\`\`
count_wms_entities(entity_type="OutboundOrders")
count_wms_entities(entity_type="Tasks", filter='z.TaskStatus == "InProcess"')
\`\`\`
---
## Basic Queries
### Get rows (with limit)
\`\`\`
query_wms_entities(entity_type="Products", limit=100)
query_wms_entities(entity_type="Tasks", limit=50)
\`\`\`
> Note: the \`select_expression\` parameter (LINQ projections) is currently unreliable
> against \`QueryExecute\` — prefer querying full rows and reading the fields you need.
## Filtering Examples
### Status filters (string enum names)
\`\`\`
# Tâches en cours / en attente
query_wms_entities(entity_type="Tasks", filter='z.TaskStatus == "InProcess"', limit=100)
query_wms_entities(entity_type="Tasks", filter='z.TaskStatus == "Pending"', limit=100)
# Ordres de sortie lancés
query_wms_entities(entity_type="OutboundOrders", filter='z.OutboundOrderStatus == "Release"', limit=100)
\`\`\`
### Date filters
> ⚠️ \`AddDays()\` et les dates relatives (\`DateTime.Now\`, \`DateTime.Today\`) ne sont
> **pas traduisibles** par le moteur de requête — toujours utiliser un littéral
> \`new DateTime(année, mois, jour)\`. Le nom du champ date dépend de l'entité
> (\`LastExecuteDate\`, \`InternalInfo.CreationDate\`, …) — le vérifier via
> \`get_entity_metadata\` ou \`docs://entities/\`.
\`\`\`
# Éléments depuis une date donnée (littéral DateTime obligatoire)
count_wms_entities(
entity_type="ShipmentTemplates",
filter='z.LastExecuteDate >= new DateTime(2025, 1, 1)'
)
\`\`\`
### Numeric filters
\`\`\`
query_wms_entities(entity_type="Stocks", filter="z.Quantity > 0", limit=100)
\`\`\`
### String filters
\`\`\`
query_wms_entities(entity_type="Products", filter='z.Code.StartsWith("ABC")', limit=100)
query_wms_entities(entity_type="Products", filter='z.Code.Contains("test")', limit=100)
\`\`\`
## Complex Filters
### Multiple conditions (AND / OR)
\`\`\`
query_wms_entities(
entity_type="Tasks",
filter='z.TaskStatus == "Pending" && z.Priority > 50',
limit=100
)
query_wms_entities(
entity_type="OutboundOrders",
filter='z.OutboundOrderStatus == "Release" || z.OutboundOrderStatus == "Creating"',
limit=100
)
\`\`\`
## Common Patterns
### Find an entity by code
\`\`\`
query_wms_entities(entity_type="Products", filter='z.Code == "PROD123"', limit=1)
query_wms_entities(entity_type="OutboundOrders", filter='z.Code == "ORDER123"', limit=1)
\`\`\`
## Performance Tips
1. **Always use limits** — max 1000 rows per query.
2. **Use \`count_wms_entities\` for counts** — never fetch rows just to count them.
3. **Verify enum values first** — Reading model uses string enum names.
4. **Use specific filters** — narrow results at the API level.
---
**Note:** All queries respect the maximum limit of 1000 rows configured in the server.
`;
}
module.exports = {
listResources,
readResource,
};
+141
View File
@@ -0,0 +1,141 @@
/**
* WMS Entity Resources
* Provides overview of WMS entity types available via Query API
*/
function listResources() {
return [
{
uri: 'wms://entities',
name: 'WMS Entities Overview',
description: 'Overview of all WMS entity types available for querying',
mimeType: 'text/markdown',
},
];
}
async function readResource(uri) {
let content;
switch (uri) {
case 'wms://entities':
content = getEntitiesOverview();
break;
default:
throw new Error(`Unknown WMS entity resource: ${uri}`);
}
return {
contents: [{
uri,
mimeType: 'text/markdown',
text: content,
}],
};
}
function getEntitiesOverview() {
return `# WMS Entities — Reference Guide
## Tools to use
- **\`count_wms_entities\`** — count with optional filter (use for "combien", "nombre de")
- **\`query_wms_entities\`** — fetch rows with select/filter/limit
- **\`get_entity_schema\`** — discover field names by fetching one sample record
- **\`call_query_api\`** — raw LINQ query, full control
---
## Note importante : Reading model vs Writing model
Le \`QueryExecute\` (Context.*) interroge le **Reading model** — les champs de statut sont des **strings**, pas des entiers ni des enums C#.
---
## OutboundOrders — Ordres de sortie
**Reading model:** \`Mecalux.ITSW.EasyWMS.Modules.Expeditions.Reading.Domain.OutboundOrder\`
**Status field:** \`OutboundOrderStatus\` → **string**
| Statut | Filtre LINQ |
|---|---|
| Lancé | \`z.OutboundOrderStatus == "Launched"\` |
| En attente | \`z.OutboundOrderStatus == "Waiting"\` |
| En création | \`z.OutboundOrderStatus == "Creating"\` |
**Champs clés :** \`Code\`, \`OutboundOrderStatus\`, \`WarehouseCode\`, \`AccountCode\`, \`RouteCode\`, \`Priority\`, \`AssignedUser\`, \`NumPendingTasks\`, \`NumReleasedLines\`, \`HasTroubles\`, \`ShippingDeadline\`
**Exemples:**
\`\`\`
// Compter les ODS lancés
count_wms_entities(entity_type="OutboundOrders", filter='z.OutboundOrderStatus == "Launched"')
// Lister les ODS lancés avec infos clés
query_wms_entities(entity_type="OutboundOrders",
select_expression="z => new { z.Code, z.OutboundOrderStatus, z.AccountCode, z.NumPendingTasks, z.HasTroubles }",
filter='z.OutboundOrderStatus == "Launched"', limit=100)
\`\`\`
---
## InboundOrders — Ordres d'entrée
**Status field:** \`InboundStatus\` → **string**
| Statut | Filtre LINQ |
|---|---|
| Réception en attente | \`z.InboundStatus == "ReceptionPending"\` |
| Réception en cours | \`z.InboundStatus == "Receiving"\` |
| Complété | \`z.InboundStatus == "Completed"\` |
| Reçu partiellement | \`z.InboundStatus == "PartiallyReceived"\` |
| Annulé | \`z.InboundStatus == "Cancelled"\` |
| Fermeture | \`z.InboundStatus == "Closing"\` |
---
## Tasks — Tâches
**Status field:** \`TaskStatus\` → **string** (valeurs à confirmer via get_entity_schema)
\`\`\`
// Schéma exact avec valeurs d'exemple
get_entity_schema(entity_type="Tasks")
\`\`\`
---
## Kits
\`\`\`
count_wms_entities(entity_type="Kits", filter="z.IsEnable == true")
\`\`\`
---
## Autres entités disponibles (Reading model, 278 au total)
**Opérations :** Tasks, Stocks, ProductLocations, ContainerLock, CountOrder, CountOrderLine
**Entrepôt :** Location, Aisle, AisleType, Division, Container, ContainerType
**Entrées :** InboundOrders, Receptions, InboundClass
**Sorties :** OutboundOrders, Shipment, OutboundClass, Wave
**Référentiel :** Products, Accounts, Suppliers, Kits, Aliases, Agency, Route
**Pour connaître les champs exacts d'une entité :**
1. \`get_entity_metadata(entity_name="...")\` — métadonnées officielles via l'API
2. \`get_entity_schema(entity_type="...")\` — exemple live avec tous les champs
---
## Note sur \`Take()\`
Certaines entités (dont OutboundOrders) peuvent retourner HTTP 500 si \`Take()\` est ajouté.
- **count_wms_entities** n'utilise jamais \`Take()\` → toujours sûr
- **query_wms_entities** utilise \`Take(limit)\` → préférer une limite raisonnable (100-500)
`;
}
module.exports = {
listResources,
readResource,
};
+96
View File
@@ -0,0 +1,96 @@
/**
* Workflow Resources
* Provides context about workflows available via API
*/
function listResources() {
return [
{
uri: 'workflows://overview',
name: 'Workflow Overview',
description: 'Overview of workflow system and available categories',
mimeType: 'text/markdown',
},
];
}
async function readResource(uri) {
let content;
switch (uri) {
case 'workflows://overview':
content = getWorkflowOverview();
break;
default:
throw new Error(`Unknown workflow resource: ${uri}`);
}
return {
contents: [{
uri,
mimeType: 'text/markdown',
text: content,
}],
};
}
function getWorkflowOverview() {
return `# WMS Workflows
## Overview
Workflows are retrieved via the **Workflow API** (\`/AD/api/Workflow/GetByApplication\`).
**Total Workflows:** ~3712
**Data Source:** API endpoint (lazy loaded with 1-hour cache)
**Pagination:** Configurable page size (default: 5000)
## Available Tools
Use these tools to interact with workflows:
### \`search_workflows\`
Search workflows by name, description, or code.
- Supports category filtering
- Returns workflow metadata
- Workflows are cached after first request
### \`get_workflow_details\`
Get complete details of a specific workflow by ID or code.
### \`list_workflow_categories\`
List all available workflow categories with statistics.
## Common Workflow Categories
- **EasyWMS**: Core WMS workflows
- **CustomApplication**: Custom workflows
- **Deliveries**: Delivery and shipping workflows
- **AccountDirective**: Account management workflows
- **Notifications**: Notification workflows
- **LaborManagement**: Labor management workflows
## Usage Examples
\`\`\`
# Search for order-related workflows
search_workflows(query="Order", limit=20)
# Get workflow details
get_workflow_details(workflow_id="abc123")
# List all categories
list_workflow_categories()
\`\`\`
---
**Note:** Workflows are fetched from the API only when first requested, then cached for 1 hour to improve performance.
`;
}
module.exports = {
listResources,
readResource,
};
+259
View File
@@ -0,0 +1,259 @@
/**
* Application Dictionary Service
* Handles fetching of all AD element types (Commands, Queries, Dialogs, Views, etc.)
* Implements lazy loading and caching similar to workflow-service.js
*/
const apiService = require('./api-service').getInstance();
const profileManager = require('../config/profile-manager');
// Cache state - one cache per element type
const cache = {};
const cacheTimestamps = {};
const CACHE_TTL = parseInt(process.env.WORKFLOW_CACHE_TTL) || 3600000; // 1 hour
// Invalidate all caches when profile changes — AD elements are per-tenant.
profileManager.onSwitch(() => invalidateCache());
// Element types and their recommended page sizes
// Note: WorkflowAction and WritingModel removed (404 Not Found on API)
const AD_ELEMENT_TYPES = {
// Heavy types requiring pagination
Workflow: 5000,
View: 200,
Resource: 15000,
// Light types - fetch all at once
Command: 100000,
Dashboard: 100000,
Dialog: 100000,
Entity: 100000,
Event: 100000,
FieldType: 100000,
Hook: 100000,
List: 100000,
Query: 100000,
Record: 100000,
Relationship: 100000,
Report: 100000,
Subscription: 100000,
TimelineTemplate: 100000,
Toggle: 100000,
Validator: 100000,
ViewGroup: 100000
};
/**
* Check if cache is valid for a given element type
*/
function isCacheValid(elementType) {
if (!cache[elementType] || !cacheTimestamps[elementType]) {
return false;
}
const now = Date.now();
const age = now - cacheTimestamps[elementType];
return age < CACHE_TTL;
}
/**
* Get all elements of a specific type from AD API
* Implements lazy loading with caching and pagination
*
* @param {string} elementType - Type of element (Command, Query, Dialog, etc.)
* @returns {Promise<Array>} Array of elements
*/
async function getElements(elementType) {
// Validate element type
if (!AD_ELEMENT_TYPES[elementType]) {
throw new Error(`Unknown element type: ${elementType}. Valid types: ${Object.keys(AD_ELEMENT_TYPES).join(', ')}`);
}
// Check cache
if (isCacheValid(elementType)) {
console.error(`[AD] Cache hit: ${elementType} (${cache[elementType].length} elements)`);
return cache[elementType];
}
console.error(`[AD] Cache expired or empty, fetching ${elementType}...`);
try {
let allElements = [];
let offset = 0;
const pageSize = AD_ELEMENT_TYPES[elementType];
const profile = profileManager.getCurrent();
const application = profile.application;
const tenant = profile.tenant;
while (true) {
const body = [application, tenant, pageSize, offset];
console.error(`[AD] Fetching ${elementType}: offset=${offset}, pageSize=${pageSize}`);
// Use AD API (useAdApi=true)
const response = await apiService.post(`/${elementType}/GetByApplication`, body, true);
// Extract entities array from response
const elements = response?.entities || [];
// Check if response is valid
if (!elements || elements.length === 0) {
console.error(`[AD] No more ${elementType} to fetch`);
break;
}
allElements = allElements.concat(elements);
console.error(`[AD] Fetched ${elements.length} ${elementType} (total: ${allElements.length})`);
// If we got less than page size, we've reached the last page
if (elements.length < pageSize) {
console.error(`[AD] Last page of ${elementType} reached`);
break;
}
offset += pageSize;
}
// Update cache
cache[elementType] = allElements;
cacheTimestamps[elementType] = Date.now();
console.error(`[AD] Successfully cached ${allElements.length} ${elementType}`);
return allElements;
} catch (error) {
console.error(`[AD] Error fetching ${elementType}:`, error.message);
throw new Error(`Failed to fetch ${elementType}: ${error.message}`);
}
}
/**
* Search elements by query string
* @param {string} elementType - Type of element
* @param {string} query - Search query (matches name, description, etc.)
* @param {number} limit - Maximum results to return
*/
async function searchElements(elementType, query, limit = 50) {
const elements = await getElements(elementType);
if (!query) {
return elements.slice(0, limit);
}
const lowerQuery = query.toLowerCase();
const results = elements.filter(e => {
const name = (e.name || e.Name || '').toLowerCase();
const description = (e.description || e.Description || '').toLowerCase();
const code = (e.code || e.Code || '').toLowerCase();
return name.includes(lowerQuery) ||
description.includes(lowerQuery) ||
code.includes(lowerQuery);
});
return results.slice(0, limit);
}
/**
* Get element details by ID or name
* @param {string} elementType - Type of element
* @param {string|number} elementId - Element ID or name
*/
async function getElementDetails(elementType, elementId) {
const elements = await getElements(elementType);
// Try to find by Id, id, Code, code, Name, or name
const element = elements.find(e =>
e.id === elementId ||
e.Id === elementId ||
e.id === parseInt(elementId) ||
e.Id === parseInt(elementId) ||
e.Code === elementId ||
e.code === elementId ||
e.Name === elementId ||
e.name === elementId
);
if (!element) {
throw new Error(`${elementType} not found: ${elementId}`);
}
return element;
}
/**
* Get application summary (count of each element type)
* Only loads types that are already cached to avoid long wait times
*/
function getApplicationSummary() {
const summary = {};
Object.keys(AD_ELEMENT_TYPES).forEach(type => {
if (cache[type]) {
summary[type] = {
count: cache[type].length,
cached: true,
cacheAge: cacheTimestamps[type] ? Math.floor((Date.now() - cacheTimestamps[type]) / 1000) : null
};
} else {
summary[type] = {
count: 0,
cached: false,
cacheAge: null
};
}
});
return summary;
}
/**
* Invalidate cache for a specific type or all types
*/
function invalidateCache(elementType = null) {
if (elementType) {
delete cache[elementType];
delete cacheTimestamps[elementType];
console.error(`[AD] Cache invalidated: ${elementType}`);
} else {
Object.keys(cache).forEach(k => {
delete cache[k];
delete cacheTimestamps[k];
});
console.error('[AD] All caches invalidated');
}
}
/**
* Get cache status
*/
function getCacheStatus() {
const status = {};
Object.keys(AD_ELEMENT_TYPES).forEach(type => {
status[type] = {
cached: !!cache[type],
count: cache[type] ? cache[type].length : 0,
timestamp: cacheTimestamps[type],
age: cacheTimestamps[type] ? Math.floor((Date.now() - cacheTimestamps[type]) / 1000) : null,
valid: isCacheValid(type)
};
});
return status;
}
/**
* Get list of available element types
*/
function getAvailableTypes() {
return Object.keys(AD_ELEMENT_TYPES).sort();
}
module.exports = {
getElements,
searchElements,
getElementDetails,
getApplicationSummary,
invalidateCache,
getCacheStatus,
getAvailableTypes,
AD_ELEMENT_TYPES
};
+393
View File
@@ -0,0 +1,393 @@
/**
* WMS API Service
* Handles OAuth authentication and HTTP requests to WMS APIs
* Supports both ApplicationService API and Workflow (AD) API
*
* All WMS-specific settings (host, credentials, tenant, application) are
* read from the active profile via profile-manager.
*/
const axios = require('axios');
const https = require('https');
const profileManager = require('../config/profile-manager');
class APIService {
constructor() {
this.token = null;
this.refreshToken = null;
this.tokenTimestamp = null;
this.tokenRefreshThreshold = parseInt(process.env.TOKEN_REFRESH_THRESHOLD) || 1000; // seconds
this.tokenMaxAge = parseInt(process.env.TOKEN_MAX_AGE) || 1190; // seconds
// Create axios instance with SSL verification disabled (for internal servers)
this.httpClient = axios.create({
httpsAgent: new https.Agent({
rejectUnauthorized: false
}),
timeout: parseInt(process.env.QUERY_TIMEOUT) || 30000
});
// Reset token when the active profile changes — credentials no longer apply.
profileManager.onSwitch(() => this.resetToken());
}
/**
* Get token age in seconds
*/
getTokenAge() {
if (!this.tokenTimestamp) return Infinity;
return (Date.now() - this.tokenTimestamp) / 1000;
}
/**
* Check if token needs refresh
*/
needsRefresh() {
return this.getTokenAge() > this.tokenRefreshThreshold;
}
/**
* Authenticate and get initial OAuth token
*/
async authenticate() {
const profile = profileManager.getCurrent();
try {
console.error(`[API] Authenticating profile="${profile.name}" tenant="${profile.tenant}" ...`);
const response = await this.httpClient.post(
profile.tokenUrl,
new URLSearchParams({
grant_type: 'password',
tenant_code: profile.tenant,
username: profile.username,
password: profile.password
}).toString(),
{
headers: {
'Authorization': profile.auth,
'Content-Type': 'application/x-www-form-urlencoded'
}
}
);
this.token = response.data.access_token;
this.refreshToken = response.data.refresh_token;
this.tokenTimestamp = Date.now();
console.error(`[API] Authentication successful. Token expires in ~${this.tokenMaxAge}s`);
return this.token;
} catch (error) {
console.error('[API] Authentication failed:', error.message);
throw new Error(`Authentication failed: ${error.message}`);
}
}
/**
* Refresh OAuth token
*/
async refreshOAuthToken() {
const tokenAge = this.getTokenAge();
try {
// If token is too old (>= maxAge), use password grant
if (tokenAge >= this.tokenMaxAge) {
console.error('[API] Token too old, re-authenticating with password...');
return await this.authenticate();
}
// Otherwise use refresh_token grant
console.error('[API] Refreshing token with refresh_token grant...');
const profile = profileManager.getCurrent();
const response = await this.httpClient.post(
profile.tokenUrl,
new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: this.refreshToken
}).toString(),
{
headers: {
'Authorization': profile.auth,
'Content-Type': 'application/x-www-form-urlencoded'
}
}
);
this.token = response.data.access_token;
this.refreshToken = response.data.refresh_token;
this.tokenTimestamp = Date.now();
console.error('[API] Token refreshed successfully');
return this.token;
} catch (error) {
console.error('[API] Token refresh failed, re-authenticating:', error.message);
return await this.authenticate();
}
}
/**
* Ensure token is valid before making requests
*/
async ensureTokenValid() {
if (!this.token) {
await this.authenticate();
} else if (this.needsRefresh()) {
await this.refreshOAuthToken();
}
}
/**
* Make a POST request to WMS API
* @param {string} endpoint - API endpoint (e.g., '/QueryExecute' or '/AD/api/Workflow/GetByApplication')
* @param {object} data - Request body
* @param {boolean} useAdApi - Use AD API base URL instead of ApplicationService
*/
async post(endpoint, data, useAdApi = false) {
await this.ensureTokenValid();
const profile = profileManager.getCurrent();
const baseUrl = useAdApi ? profile.workflowApiBase : profile.apiBaseUrl;
const url = endpoint.startsWith('http') ? endpoint : `${baseUrl}${endpoint}`;
try {
console.error(`[API] POST ${endpoint}`);
const response = await this.httpClient.post(url, data, {
headers: {
'Authorization': `Bearer ${this.token}`,
'Content-Type': 'application/json',
'Accept': 'application/json'
}
});
return response.data;
} catch (error) {
console.error(`[API] Request failed: ${error.message}`);
// If unauthorized, try refreshing token and retry once
if (error.response?.status === 401) {
console.error('[API] Unauthorized, refreshing token and retrying...');
await this.refreshOAuthToken();
const retryResponse = await this.httpClient.post(url, data, {
headers: {
'Authorization': `Bearer ${this.token}`,
'Content-Type': 'application/json',
'Accept': 'application/json'
}
});
return retryResponse.data;
}
throw error;
}
}
/**
* Make a GET request to WMS API
* @param {string} endpoint - API endpoint
* @param {object} params - Query parameters
* @param {boolean} useAdApi - Use AD API base URL instead of ApplicationService
*/
async get(endpoint, params = {}, useAdApi = false) {
await this.ensureTokenValid();
const profile = profileManager.getCurrent();
const baseUrl = useAdApi ? profile.workflowApiBase : profile.apiBaseUrl;
const url = endpoint.startsWith('http') ? endpoint : `${baseUrl}${endpoint}`;
try {
console.error(`[API] GET ${endpoint}`);
const response = await this.httpClient.get(url, {
params,
headers: {
'Authorization': `Bearer ${this.token}`,
'Accept': 'application/json'
}
});
return response.data;
} catch (error) {
console.error(`[API] Request failed: ${error.message}`);
// If unauthorized, try refreshing token and retry once
if (error.response?.status === 401) {
console.error('[API] Unauthorized, refreshing token and retrying...');
await this.refreshOAuthToken();
const retryResponse = await this.httpClient.get(url, {
params,
headers: {
'Authorization': `Bearer ${this.token}`,
'Accept': 'application/json'
}
});
return retryResponse.data;
}
throw error;
}
}
/**
* Execute a LINQ query via WMS QueryExecute API.
* Take/Skip/Select are top-level API params. OrderBy MUST be embedded in the Expression.
* The Expression should contain: base path + optional Where + OrderBy (required when Take is used):
* e.g. "Context.OutboundOrders.Where(z => z.OutboundOrderStatus == \"Release\").OrderBy(z => z.Id)"
*
* @param {string} expression - LINQ expression (Context.Entity or Context.Entity.Where(...))
* @param {object} options - { take, skip, select, orderBy, inlineCount }
*/
async executeQuery(expression, options = {}) {
const { take, skip, select, orderBy, inlineCount } = options;
const body = {
Application: profileManager.getCurrent().application,
QueryType: 0, // Reading = 0 (status fields are strings), Writing = 1 (enums)
Expression: expression,
};
if (take != null) body.Take = take;
if (skip != null) body.Skip = skip;
if (select != null) body.Select = select;
if (orderBy != null) body.OrderBy = orderBy;
if (inlineCount != null) body.InlineCount = inlineCount;
const raw = await this.post('/QueryExecute', body);
return this._parseQueryResponse(raw);
}
/**
* Parse QueryExecute response — handles both response shapes:
* - Flat array (legacy / full-LINQ-in-expression queries)
* - Table object { Table: { Columns, Rows } }
*/
_parseQueryResponse(raw) {
if (Array.isArray(raw)) return raw;
if (raw?.Table?.Rows) {
return raw.Table.Rows
.filter(Boolean)
.map(row => row.Values ?? row);
}
return raw;
}
/**
* Execute a scalar LINQ query (Count, Sum, etc.) via QueryScalarExecute.
* Returns the scalar value directly.
* @param {string} fullExpression - e.g. "Context.OutboundOrders.Where(...).Count()"
*/
async executeScalarQuery(fullExpression) {
const body = {
Application: profileManager.getCurrent().application,
QueryType: 0, // Reading = 0 — string enum names in filters (Writing=1 fails with enum comparisons)
Expression: fullExpression,
};
const result = await this.post('/QueryScalarExecute', body);
return result?.Rows?.[0]?.Values?.Column ?? result;
}
/**
* List queryable entities via Metadata API.
* @param {string} [applicationName] - Override application name (defaults to active profile's app)
*/
async getMetadataEntities(applicationName) {
const app = applicationName || profileManager.getCurrent().application;
return await this.get(`/Metadata/Entities?applicationName=${encodeURIComponent(app)}`);
}
/**
* Get properties of a specific entity via Metadata API.
* @param {string} assemblyFullName - e.g. "Mecalux.ITSW.EasyWMS.Modules.Contracts"
* @param {string} fullName - e.g. "Mecalux.ITSW.EasyWMS.Modules.Contracts.Domain.OutboundOrder"
*/
async getEntityProperties(assemblyFullName, fullName) {
const qs = `assemblyFullName=${encodeURIComponent(assemblyFullName)}&fullName=${encodeURIComponent(fullName)}`;
return await this.get(`/Metadata/EntityProperties?${qs}`);
}
/**
* Get GenericSearch categories (indexed document types).
* @param {string} [applicationName]
*/
async getSearchCategories(applicationName) {
const app = applicationName || profileManager.getCurrent().application;
return await this.get(`/GenericSearch/Categories?applicationName=${encodeURIComponent(app)}`);
}
/**
* Full-text search via GenericSearch API.
* @param {string} query - Search keyword
* @param {string[]} categories - Document categories to search (from getSearchCategories)
* @param {number} take - Max results
* @param {number} skip - Offset
* @param {string} [applicationName]
*/
async genericSearch(query, categories = [], take = 20, skip = 0, applicationName) {
const body = {
ApplicationName: applicationName || profileManager.getCurrent().application,
Query: query,
Categories: categories,
Take: take,
Skip: skip,
};
return await this.post('/GenericSearch/Search', body);
}
/**
* Execute a WMS command
* @param {string} commandName - Full command name
* @param {object} properties - Command properties
*/
async executeCommand(commandName, properties) {
// Use command_name as-is — appending assembly suffix causes FileLoadException.
// The InternalCommandName from AD already contains the correct fully-qualified name.
const command = [{
Name: commandName,
Properties: properties
}];
return await this.post('/CommandExecute', command);
}
/**
* Test API connection
*/
async testConnection() {
try {
await this.ensureTokenValid();
return true;
} catch (error) {
console.error('[API] Connection test failed:', error.message);
return false;
}
}
/**
* Reset token (force re-authentication)
*/
resetToken() {
if (this.token) {
console.error('[API] Token reset');
}
this.token = null;
this.refreshToken = null;
this.tokenTimestamp = null;
}
}
// Singleton instance
let instance = null;
module.exports = {
getInstance() {
if (!instance) {
instance = new APIService();
}
return instance;
}
};
+412
View File
@@ -0,0 +1,412 @@
const fs = require('fs').promises;
const path = require('path');
const profileManager = require('../config/profile-manager');
/**
* Service de gestion des logs
* Supporte plusieurs emplacements de logs :
* - IIS logs: \\<host>\inetpub\logs\LogFiles\Mecalux (avec sous-dossiers)
* - ETL middleware: \\<host>\ProgramData\Mecalux\ETLLogs
*
* LOGS_PATH dans .env peut contenir le placeholder "{host}" qui est remplacé
* par le host du profil actif à chaque appel.
*
* En mode SaaS (profile.saas === true) l'accès aux logs est bloqué car le
* WMS est hébergé dans le cloud et le filesystem n'est pas joignable.
*/
const DEFAULT_LOG_PATHS = [
'\\\\{host}\\inetpub\\logs\\LogFiles\\Mecalux',
'\\\\{host}\\ProgramData\\Mecalux\\ETLLogs',
];
const LOG_FILE_PATTERN = process.env.LOG_FILE_PATTERN || '*.log';
/**
* Retourne les chemins de logs à scanner pour le profil actif.
* Substitue {host} par l'hostname du profil.
* Lance une erreur si le profil est en mode SaaS.
*/
function getLogPaths() {
const profile = profileManager.getCurrent();
if (profile.saas) {
throw new Error(`Log access is disabled for SaaS profile "${profile.name}". The WMS is cloud-hosted — local log files are not reachable. Use the WMS API (query/command/workflow tools) instead.`);
}
const templates = process.env.LOGS_PATH
? process.env.LOGS_PATH.split(';').map(p => p.trim()).filter(Boolean)
: DEFAULT_LOG_PATHS;
return templates.map(t => t.replace(/\{host\}/g, profile.host));
}
/**
* Scanne récursivement un dossier pour trouver tous les fichiers .log
* @param {string} dir - Dossier à scanner
* @param {Array} fileList - Liste accumulée des fichiers (pour la récursion)
* @returns {Promise<Array>} - Liste des fichiers trouvés
*/
async function scanLogsRecursively(dir, fileList = []) {
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
// Récursion dans les sous-dossiers
await scanLogsRecursively(fullPath, fileList);
} else if (entry.isFile() && entry.name.endsWith('.log')) {
try {
const stats = await fs.stat(fullPath);
fileList.push({
name: entry.name,
path: fullPath,
relativePath: path.relative(dir, fullPath),
directory: path.dirname(fullPath),
size: stats.size,
modified: stats.mtime,
});
} catch (statErr) {
// Ignorer les fichiers inaccessibles
console.error(`Cannot access file ${fullPath}: ${statErr.message}`);
}
}
}
} catch (err) {
// Ne pas planter si un dossier n'existe pas ou n'est pas accessible
console.error(`Cannot scan directory ${dir}: ${err.message}`);
}
return fileList;
}
/**
* Liste tous les fichiers de logs dans tous les répertoires configurés
* @returns {Promise<Array>} - Liste des fichiers de logs
*/
async function listLogFiles() {
const logPaths = getLogPaths();
const allFiles = [];
// Scanner chaque chemin configuré
for (const logPath of logPaths) {
const files = await scanLogsRecursively(logPath);
allFiles.push(...files);
}
if (allFiles.length === 0) {
const pathsList = logPaths.join(', ');
throw new Error(`No log files found in configured paths: ${pathsList}`);
}
// Trier par date de modification (plus récent en premier)
allFiles.sort((a, b) => b.modified - a.modified);
return allFiles;
}
/**
* Trouve le fichier de log le plus récent
* @returns {Promise<string>} - Chemin du fichier le plus récent
*/
async function findLatestLogFile() {
const files = await listLogFiles();
if (files.length === 0) {
throw new Error('No log files found');
}
return files[0].path;
}
/**
* Vérifie si un fichier existe
* @param {string} filePath
* @returns {Promise<boolean>}
*/
async function fileExists(filePath) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
/**
* Résout le chemin d'un fichier de log en 3 passes :
* 1. Chemin direct (LOGS_PATH + logFile, peut inclure un sous-dossier)
* 2. Nom de fichier exact dans chaque sous-dossier immédiat de LOGS_PATH
* 3. Correspondance floue (partielle, insensible à la casse)
* En cas d'échec, retourne une erreur avec la liste des fichiers disponibles.
* @param {string} logFile - Nom ou chemin partiel du fichier
* @returns {Promise<string>} - Chemin absolu résolu
*/
async function resolveLogFilePath(logFile) {
// Si chemin absolu fourni directement
if (path.isAbsolute(logFile)) {
if (await fileExists(logFile)) return logFile;
throw new Error(`File not found: ${logFile}`);
}
const basePath = getLogPaths()[0];
// Passe 1 — Chemin direct (ex: "ApplicationDictionary\ApplicationDictionary.log")
const directPath = path.join(basePath, logFile);
if (await fileExists(directPath)) return directPath;
// Passe 2 & 3 — Scanner les sous-dossiers immédiats
const fileName = path.basename(logFile);
const fileNameNoExt = path.basename(fileName, path.extname(fileName)).toLowerCase();
let subEntries = [];
try {
subEntries = await fs.readdir(basePath, { withFileTypes: true });
} catch (err) {
console.error(`Cannot read base log path ${basePath}: ${err.message}`);
}
const subDirs = subEntries.filter(e => e.isDirectory()).map(e => e.name);
// Passe 2 — Nom exact dans les sous-dossiers
for (const dir of subDirs) {
const candidate = path.join(basePath, dir, fileName);
if (await fileExists(candidate)) return candidate;
}
// Passe 3 — Correspondance floue
for (const dir of subDirs) {
let dirFiles = [];
try {
dirFiles = await fs.readdir(path.join(basePath, dir));
} catch {
continue;
}
for (const f of dirFiles) {
if (!f.endsWith('.log')) continue;
const fNoExt = path.basename(f, path.extname(f)).toLowerCase();
if (fNoExt.includes(fileNameNoExt) || fileNameNoExt.includes(fNoExt)) {
return path.join(basePath, dir, f);
}
}
}
// Aucun fichier trouvé — construire une liste utile
const available = [];
for (const dir of subDirs) {
try {
const files = await fs.readdir(path.join(basePath, dir));
files.filter(f => f.endsWith('.log')).forEach(f => available.push(`${dir}\\${f}`));
} catch { /* ignorer */ }
}
const availableMsg = available.length > 0
? `Available log files:\n${available.join('\n')}`
: `No log files found in ${basePath}`;
throw new Error(`Log file "${logFile}" not found.\n${availableMsg}`);
}
/**
* Lit les dernières lignes d'un fichier de log
* @param {number} count - Nombre de lignes à lire
* @param {string} logFile - Nom ou chemin partiel du fichier (optionnel, prend le plus récent par défaut)
* @returns {Promise<Object>} - Lignes du log
*/
async function readRecentLogs(count = 100, logFile = null) {
try {
const filePath = logFile
? await resolveLogFilePath(logFile)
: await findLatestLogFile();
// Lire le fichier complet
const content = await fs.readFile(filePath, 'utf8');
const lines = content.split('\n').filter((line) => line.trim() !== '');
// Prendre les dernières lignes
const recentLines = lines.slice(-count);
return {
file: path.basename(filePath),
fullPath: filePath,
totalLines: lines.length,
returnedLines: recentLines.length,
lines: recentLines,
};
} catch (err) {
throw new Error(`Failed to read log file: ${err.message}`);
}
}
/**
* Recherche un mot-clé dans les fichiers de logs
* @param {string} keyword - Mot-clé à rechercher
* @param {number} maxResults - Nombre maximum de résultats
* @param {number} contextLines - Nombre de lignes de contexte avant/après
* @returns {Promise<Object>} - Résultats de la recherche
*/
async function searchLogs(keyword, maxResults = 50, contextLines = 2) {
try {
const files = await listLogFiles();
const results = [];
const searchPattern = keyword.toLowerCase();
// Rechercher dans chaque fichier (du plus récent au plus ancien)
for (const file of files) {
if (results.length >= maxResults) break;
try {
const content = await fs.readFile(file.path, 'utf8');
const lines = content.split('\n');
// Rechercher dans chaque ligne
for (let i = 0; i < lines.length; i++) {
if (results.length >= maxResults) break;
const line = lines[i];
if (line.toLowerCase().includes(searchPattern)) {
// Extraire le contexte (lignes avant et après)
const start = Math.max(0, i - contextLines);
const end = Math.min(lines.length, i + contextLines + 1);
const context = lines.slice(start, end);
results.push({
file: file.name,
fullPath: file.path,
directory: file.directory,
lineNumber: i + 1,
line: line.trim(),
context: context.map((l, idx) => ({
lineNumber: start + idx + 1,
text: l.trim(),
isMatch: start + idx === i,
})),
});
}
}
} catch (readErr) {
// Ignorer les fichiers illisibles
console.error(`Cannot read file ${file.path}: ${readErr.message}`);
}
}
return {
keyword,
totalResults: results.length,
results,
scannedPaths: getLogPaths(),
};
} catch (err) {
throw new Error(`Search failed: ${err.message}`);
}
}
/**
* Recherche des erreurs dans les logs récents
* @param {number} maxResults - Nombre maximum de résultats
* @returns {Promise<Object>} - Erreurs trouvées
*/
async function findRecentErrors(maxResults = 20) {
const errorPatterns = ['error', 'exception', 'failed', 'fatal', 'critical'];
const allErrors = [];
try {
for (const pattern of errorPatterns) {
if (allErrors.length >= maxResults) break;
const results = await searchLogs(pattern, maxResults - allErrors.length, 1);
allErrors.push(...results.results);
}
// Dédupliquer par numéro de ligne et fichier
const unique = allErrors.filter(
(error, index, self) =>
index ===
self.findIndex(
(e) => e.fullPath === error.fullPath && e.lineNumber === error.lineNumber
)
);
return {
totalErrors: unique.length,
errors: unique.slice(0, maxResults),
};
} catch (err) {
throw new Error(`Failed to find errors: ${err.message}`);
}
}
/**
* Lit tout le contenu d'un fichier de log spécifique
* @param {string} logFilePath - Chemin complet du fichier
* @returns {Promise<Object>} - Contenu du fichier
*/
async function readFullLog(logFilePath) {
try {
const content = await fs.readFile(logFilePath, 'utf8');
const lines = content.split('\n').filter((line) => line.trim() !== '');
return {
file: path.basename(logFilePath),
fullPath: logFilePath,
totalLines: lines.length,
content: lines,
};
} catch (err) {
throw new Error(`Failed to read log file: ${err.message}`);
}
}
/**
* Obtient des statistiques sur les logs
* @returns {Promise<Object>} - Statistiques
*/
async function getLogStats() {
try {
const files = await listLogFiles();
const totalSize = files.reduce((sum, file) => sum + file.size, 0);
// Grouper par dossier
const byDirectory = {};
files.forEach(file => {
const dir = file.directory;
if (!byDirectory[dir]) {
byDirectory[dir] = {
directory: dir,
count: 0,
totalSize: 0,
files: [],
};
}
byDirectory[dir].count++;
byDirectory[dir].totalSize += file.size;
byDirectory[dir].files.push({
name: file.name,
sizeMB: (file.size / (1024 * 1024)).toFixed(2),
modified: file.modified.toISOString(),
});
});
return {
configuredPaths: getLogPaths(),
totalFiles: files.length,
totalSize,
totalSizeMB: (totalSize / (1024 * 1024)).toFixed(2),
oldestFile: files[files.length - 1]?.name,
newestFile: files[0]?.name,
byDirectory: Object.values(byDirectory).sort((a, b) => b.count - a.count),
};
} catch (err) {
throw new Error(`Failed to get log stats: ${err.message}`);
}
}
module.exports = {
listLogFiles,
findLatestLogFile,
readRecentLogs,
searchLogs,
findRecentErrors,
readFullLog,
getLogStats,
};
+179
View File
@@ -0,0 +1,179 @@
/**
* WMS Query Service
* Helper functions for building and executing LINQ queries via WMS API
*/
const apiService = require('./api-service').getInstance();
const constants = require('../config/constants');
/**
* Build a LINQ select expression
* @param {string} entityType - Entity type (Products, Containers, etc.)
* @param {string} selectFields - Fields to select (e.g., "z => new { z.Id, z.Name }")
* @param {string|null} filter - Optional filter (e.g., "z.Status == 'Active'")
* @param {number|null} limit - Optional limit
*/
/**
* Query WMS entities with LINQ expression
* @param {string} entityType - Entity type
* @param {string} selectExpression - LINQ select expression
* @param {string|null} filter - Optional filter
* @param {number} limit - Result limit
*/
async function queryEntities(entityType, selectExpression = 'z => z', filter = null, limit = 100) {
try {
// Enforce max limit
const maxLimit = parseInt(process.env.MAX_QUERY_ROWS) || 1000;
const actualLimit = Math.min(limit, maxLimit);
// Build expression: Context + optional Where + OrderBy (required by EF when Take is used)
let expression = `Context.${entityType}`;
if (filter) {
const whereExpr = /^\s*\w+\s*=>/.test(filter) ? filter : `z => ${filter}`;
expression += `.Where(${whereExpr})`;
}
// OrderBy must be embedded in the expression (not as a separate API param)
expression += `.OrderBy(z => z.Id)`;
console.error(`[WMSQuery] Querying ${entityType}: ${expression} | take=${actualLimit} select=${selectExpression}`);
const result = await apiService.executeQuery(expression, {
take: actualLimit,
select: selectExpression !== 'z => z' ? selectExpression : undefined,
});
return {
entityType,
expression,
limit: actualLimit,
count: Array.isArray(result) ? result.length : 0,
data: result
};
} catch (error) {
console.error(`[WMSQuery] Query failed:`, error.message);
throw new Error(`Query failed for ${entityType}: ${error.message}`);
}
}
/**
* Query entity by ID
* @param {string} entityType - Entity type
* @param {string|number} id - Entity ID
*/
async function queryById(entityType, id) {
const filter = `z => z.Id == ${id}`;
const result = await queryEntities(entityType, 'z => z', filter, 1);
if (!result.data || result.data.length === 0) {
throw new Error(`${entityType} with ID ${id} not found`);
}
return result.data[0];
}
/**
* Search entities by keyword in common fields
* @param {string} entityType - Entity type
* @param {string} keyword - Search keyword
* @param {number} limit - Result limit
*/
async function searchEntities(entityType, keyword, limit = 50) {
// Common searchable fields by entity type
const searchFields = {
'Products': ['Code', 'Description', 'Reference'],
'Containers': ['Code', 'Reference'],
'Tasks': ['Code', 'Reference'],
'InboundOrders': ['Code', 'Reference'],
'OutboundOrders': ['Code', 'Reference'],
'Accounts': ['Code', 'Name'],
'Suppliers': ['Code', 'Name']
};
const fields = searchFields[entityType] || ['Code'];
// Build filter for multiple fields (OR condition)
const conditions = fields.map(field => `z.${field}.Contains("${keyword}")`).join(' || ');
const filter = `z => ${conditions}`;
try {
return await queryEntities(entityType, 'z => z', filter, limit);
} catch (error) {
// If Contains doesn't work, try simpler approach
console.error(`[WMSQuery] Search failed, trying alternative method:`, error.message);
return await queryEntities(entityType, 'z => z', null, limit);
}
}
/**
* Get entity schema information (example entity to show available fields)
* @param {string} entityType - Entity type
*/
async function getEntitySchema(entityType) {
try {
// Query one entity to see its structure
const result = await queryEntities(entityType, 'z => z', null, 1);
if (!result.data || result.data.length === 0) {
return {
entityType,
fields: [],
message: `No ${entityType} found to inspect schema`
};
}
const sample = result.data[0];
const fields = Object.keys(sample).map(key => ({
name: key,
type: typeof sample[key],
sample: sample[key]
}));
return {
entityType,
fields,
totalFields: fields.length
};
} catch (error) {
console.error(`[WMSQuery] Failed to get schema for ${entityType}:`, error.message);
throw error;
}
}
/**
* Count entities with optional filter
* @param {string} entityType - Entity type
* @param {string|null} filter - Optional filter
*/
async function countEntities(entityType, filter = null) {
try {
// Build: Context.Entity.Where(...).Count()
const parts = [`Context.${entityType}`];
if (filter) {
const whereExpr = /^\s*\w+\s*=>/.test(filter) ? filter : `z => ${filter}`;
parts.push(`Where(${whereExpr})`);
}
parts.push('Count()');
const fullExpression = parts.join('.');
console.error(`[WMSQuery] Counting ${entityType}: ${fullExpression}`);
const count = await apiService.executeScalarQuery(fullExpression);
return {
entityType,
filter,
count
};
} catch (error) {
console.error(`[WMSQuery] Count failed:`, error.message);
throw new Error(`Count failed for ${entityType}: ${error.message}`);
}
}
module.exports = {
queryEntities,
queryById,
searchEntities,
getEntitySchema,
countEntities,
};
+229
View File
@@ -0,0 +1,229 @@
/**
* Workflow Service
* Handles workflow fetching with lazy loading and caching
* Workflows are only loaded when first requested (not at startup)
*/
const apiService = require('./api-service').getInstance();
const profileManager = require('../config/profile-manager');
// Cache state
let workflowCache = null;
let cacheTimestamp = null;
const CACHE_TTL = parseInt(process.env.WORKFLOW_CACHE_TTL) || 3600000; // 1 hour in milliseconds
// Clear cache when profile changes — workflows are per-tenant, so the previous
// profile's cache is meaningless after a switch.
profileManager.onSwitch(() => clearCache());
/**
* Check if cache is still valid
*/
function isCacheValid() {
if (!workflowCache || !cacheTimestamp) {
return false;
}
const now = Date.now();
const age = now - cacheTimestamp;
return age < CACHE_TTL;
}
/**
* Fetch all workflows from API with pagination
* Uses high page size (5000) to minimize API calls
*/
async function fetchAllWorkflows() {
// Check cache validity
if (isCacheValid()) {
console.error('[Workflow] Using cached data');
return workflowCache;
}
console.error('[Workflow] Cache expired or empty, fetching from API...');
try {
let allWorkflows = [];
let offset = 0;
const pageSize = parseInt(process.env.WORKFLOW_PAGE_SIZE) || 5000;
const profile = profileManager.getCurrent();
const application = profile.application;
const tenant = profile.tenant;
while (true) {
const body = [application, tenant, pageSize, offset];
console.error(`[Workflow] Fetching page: offset=${offset}, pageSize=${pageSize}`);
// Use AD API (useAdApi=true)
const response = await apiService.post('/Workflow/GetByApplication', body, true);
// Extract entities array from response
const workflows = response?.entities || [];
// Check if response is valid
if (!workflows || workflows.length === 0) {
console.error('[Workflow] No more workflows to fetch');
break;
}
allWorkflows = allWorkflows.concat(workflows);
console.error(`[Workflow] Fetched ${workflows.length} workflows (total: ${allWorkflows.length})`);
// If we got less than page size, we've reached the last page
if (workflows.length < pageSize) {
console.error('[Workflow] Last page reached');
break;
}
offset += pageSize;
}
// Update cache
workflowCache = allWorkflows;
cacheTimestamp = Date.now();
console.error(`[Workflow] Successfully cached ${allWorkflows.length} workflows`);
return allWorkflows;
} catch (error) {
console.error('[Workflow] Error fetching workflows:', error.message);
throw new Error(`Failed to fetch workflows: ${error.message}`);
}
}
/**
* Search workflows by query string
* @param {string} query - Search query (matches name, description, etc.)
* @param {string|null} category - Optional category filter
* @param {number} limit - Maximum results to return
*/
async function searchWorkflows(query, category = null, limit = 50) {
const workflows = await fetchAllWorkflows();
let results = workflows;
// Filter by query if provided
if (query) {
const lowerQuery = query.toLowerCase();
results = results.filter(w => {
const name = (w.name || w.Name || '').toLowerCase();
const description = (w.description || w.Description || '').toLowerCase();
const code = (w.code || w.Code || '').toLowerCase();
return name.includes(lowerQuery) ||
description.includes(lowerQuery) ||
code.includes(lowerQuery);
});
}
// Filter by category if provided
if (category) {
const lowerCategory = category.toLowerCase();
results = results.filter(w => {
const wfCategory = (w.category || w.Category || '').toLowerCase();
return wfCategory.includes(lowerCategory);
});
}
// Limit results
return results.slice(0, limit);
}
/**
* Get workflow details by ID
* @param {string|number} workflowId - Workflow ID
*/
async function getWorkflowDetails(workflowId) {
const workflows = await fetchAllWorkflows();
// Try to find by Id, id, Code, code, Name, or name
const workflow = workflows.find(w =>
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
);
if (!workflow) {
throw new Error(`Workflow not found: ${workflowId}`);
}
return workflow;
}
/**
* List all workflow categories
*/
async function listWorkflowCategories() {
const workflows = await fetchAllWorkflows();
// Extract unique categories (try both lowercase and uppercase)
const categories = new Set();
workflows.forEach(w => {
const category = w.category || w.Category;
if (category) {
categories.add(category);
}
});
// Sort alphabetically
return Array.from(categories).sort();
}
/**
* Get workflow statistics
*/
async function getWorkflowStats() {
const workflows = await fetchAllWorkflows();
const categories = await listWorkflowCategories();
// Count workflows per category
const categoryCounts = {};
workflows.forEach(w => {
const cat = w.category || w.Category || 'Uncategorized';
categoryCounts[cat] = (categoryCounts[cat] || 0) + 1;
});
return {
total: workflows.length,
categories: categories.length,
categoryCounts,
cacheAge: cacheTimestamp ? Math.floor((Date.now() - cacheTimestamp) / 1000) : null
};
}
/**
* Clear workflow cache (force refresh on next request)
*/
function clearCache() {
workflowCache = null;
cacheTimestamp = null;
console.error('[Workflow] Cache cleared');
}
/**
* Get cache status
*/
function getCacheStatus() {
return {
cached: workflowCache !== null,
count: workflowCache ? workflowCache.length : 0,
timestamp: cacheTimestamp,
age: cacheTimestamp ? Math.floor((Date.now() - cacheTimestamp) / 1000) : null,
valid: isCacheValid()
};
}
module.exports = {
fetchAllWorkflows,
searchWorkflows,
getWorkflowDetails,
listWorkflowCategories,
getWorkflowStats,
clearCache,
getCacheStatus
};
+291
View File
@@ -0,0 +1,291 @@
/**
* Application Dictionary Tools
* MCP tools for interacting with AD elements (Commands, Queries, Dialogs, Views, etc.)
*/
const adService = require('../services/ad-service');
/**
* List available AD tools
*/
function listTools() {
return [
{
name: 'get_application_summary',
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: {
type: 'object',
properties: {},
},
},
{
name: 'get_ad_elements',
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: {
type: 'object',
properties: {
element_type: {
type: 'string',
description: 'Element type: Command, Query, Dialog, View, Entity, Event, FieldType, Hook, List, Record, Relationship, Report, Resource, Subscription, TimelineTemplate, Toggle, Validator, ViewGroup, Workflow, Dashboard',
},
limit: {
type: 'number',
description: 'Maximum number of elements to return (default: 100, max: 1000)',
default: 100,
},
},
required: ['element_type'],
},
},
{
name: 'search_ad_elements',
description: 'Search Application Dictionary elements by name, description, or code. Searches within a specific element type.',
inputSchema: {
type: 'object',
properties: {
element_type: {
type: 'string',
description: 'Element type to search in',
},
query: {
type: 'string',
description: 'Search query (matches name, description, code)',
},
limit: {
type: 'number',
description: 'Maximum results (default: 50)',
default: 50,
},
},
required: ['element_type', 'query'],
},
},
{
name: 'get_ad_element_details',
description: 'Get detailed information about a specific AD element by ID or name',
inputSchema: {
type: 'object',
properties: {
element_type: {
type: 'string',
description: 'Element type',
},
element_id: {
type: 'string',
description: 'Element ID or name',
},
},
required: ['element_type', 'element_id'],
},
},
{
name: 'list_ad_types',
description: 'List all available Application Dictionary element types',
inputSchema: {
type: 'object',
properties: {},
},
},
];
}
/**
* Execute AD tool
*/
async function executeTool(name, args) {
try {
switch (name) {
case 'get_application_summary':
return await getApplicationSummaryTool(args);
case 'get_ad_elements':
return await getADElementsTool(args);
case 'search_ad_elements':
return await searchADElementsTool(args);
case 'get_ad_element_details':
return await getADElementDetailsTool(args);
case 'list_ad_types':
return await listADTypesTool(args);
default:
throw new Error(`Unknown AD tool: ${name}`);
}
} catch (error) {
console.error(`[ADTools] Error executing ${name}:`, error.message);
return {
content: [{
type: 'text',
text: JSON.stringify({
success: false,
error: error.message,
tool: name
}, null, 2)
}],
isError: true
};
}
}
/**
* Tool: get_application_summary
*/
async function getApplicationSummaryTool(args) {
console.error('[ADTools] Getting application summary');
const summary = adService.getApplicationSummary();
// Calculate totals
let totalCached = 0;
let totalElements = 0;
const cachedTypes = [];
const uncachedTypes = [];
Object.entries(summary).forEach(([type, info]) => {
if (info.cached) {
totalCached++;
totalElements += info.count;
cachedTypes.push(type);
} else {
uncachedTypes.push(type);
}
});
return {
content: [{
type: 'text',
text: JSON.stringify({
success: true,
summary: {
totalTypes: Object.keys(summary).length,
cachedTypes: totalCached,
uncachedTypes: uncachedTypes.length,
totalElements: totalElements
},
elementCounts: summary,
cached: cachedTypes,
notCached: uncachedTypes
}, null, 2)
}]
};
}
/**
* Tool: get_ad_elements
*/
async function getADElementsTool(args) {
const { element_type, limit = 100 } = args;
console.error(`[ADTools] Getting ${element_type} elements (limit: ${limit})`);
const elements = await adService.getElements(element_type);
// Limit results
const limitedElements = elements.slice(0, Math.min(limit, 1000));
// Map to simplified format
const mappedElements = limitedElements.map(e => ({
id: e.id || e.Id,
name: e.name || e.Name,
description: e.description || e.Description,
code: e.code || e.Code,
// Include a few other common fields
validFrom: e.validFrom,
validTo: e.validTo,
versionId: e.versionId
}));
return {
content: [{
type: 'text',
text: JSON.stringify({
success: true,
elementType: element_type,
count: elements.length,
returned: mappedElements.length,
elements: mappedElements
}, null, 2)
}]
};
}
/**
* Tool: search_ad_elements
*/
async function searchADElementsTool(args) {
const { element_type, query, limit = 50 } = args;
console.error(`[ADTools] Searching ${element_type}: query="${query}", limit=${limit}`);
const results = await adService.searchElements(element_type, query, limit);
// Map to simplified format
const mappedResults = results.map(e => ({
id: e.id || e.Id,
name: e.name || e.Name,
description: e.description || e.Description,
code: e.code || e.Code
}));
return {
content: [{
type: 'text',
text: JSON.stringify({
success: true,
elementType: element_type,
query,
count: mappedResults.length,
elements: mappedResults
}, null, 2)
}]
};
}
/**
* Tool: get_ad_element_details
*/
async function getADElementDetailsTool(args) {
const { element_type, element_id } = args;
console.error(`[ADTools] Getting ${element_type} details: ${element_id}`);
const element = await adService.getElementDetails(element_type, element_id);
return {
content: [{
type: 'text',
text: JSON.stringify({
success: true,
elementType: element_type,
element
}, null, 2)
}]
};
}
/**
* Tool: list_ad_types
*/
async function listADTypesTool(args) {
console.error('[ADTools] Listing AD types');
const types = adService.getAvailableTypes();
return {
content: [{
type: 'text',
text: JSON.stringify({
success: true,
count: types.length,
types
}, null, 2)
}]
};
}
module.exports = {
listTools,
executeTool,
};
+185
View File
@@ -0,0 +1,185 @@
const apiService = require('../services/api-service').getInstance();
/**
* Tools MCP pour interagir avec les APIs WMS
*/
/**
* Liste des tools disponibles
* @returns {Array} - Liste des tools
*/
function listTools() {
return [
{
name: 'call_query_api',
description: 'Appelle l\'API Query du WMS pour interroger des entités (Containers, Stocks, Tasks, Products, etc.)',
inputSchema: {
type: 'object',
properties: {
entity_type: {
type: 'string',
description: 'Type d\'entité (Containers, Stocks, ProductLocations, Tasks, Products, Accounts, Suppliers, Kits, Aliases, InboundOrders, Receptions, OutboundOrders)',
},
expression: {
type: 'string',
description: 'Expression LINQ (ex: "z => z" pour tout, "z => z.Id" pour les IDs uniquement)',
default: 'z => z',
},
filter: {
type: 'string',
description: 'Filtre LINQ optionnel (ex: "z.Id > 100")',
},
limit: {
type: 'number',
description: 'Limite de résultats (défaut: 100)',
default: 100,
},
},
required: ['entity_type'],
},
},
{
name: 'execute_command',
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: {
type: 'object',
properties: {
command_name: {
type: 'string',
description: 'InternalCommandName exact tel que retourné par get_ad_element_details (ex: "Mecalux.ITSW.EasyWMS.Modules.MasterData.Contracts.Commands.SupplierCreateCommand"). Ne pas ajouter de suffix assembly — cela cause une FileLoadException.',
},
properties: {
type: 'object',
description: 'Propriétés de la commande. Pour toute commande de création (Create), un champ Id de type GUID est requis et doit être généré à la volée (ex: crypto.randomUUID() ou uuidv4()). Ne jamais laisser Id vide ou null.',
},
},
required: ['command_name', 'properties'],
},
},
];
}
/**
* Exécute un tool API
* @param {string} name - Nom du tool
* @param {Object} args - Arguments du tool
* @returns {Promise<Object>} - Résultat du tool
*/
async function executeTool(name, args) {
switch (name) {
case 'call_query_api':
return await callQueryAPI(args);
case 'execute_command':
return await executeCommand(args);
default:
throw new Error(`Unknown API tool: ${name}`);
}
}
/**
* Tool: call_query_api
*/
async function callQueryAPI(args) {
const { entity_type, expression = 'z => z', filter, limit = 100 } = args;
try {
// Expression = Context.Entity + optional Where + OrderBy (required by EF when Take is used)
let linqExpression = `Context.${entity_type}`;
if (filter) {
const whereExpr = /^\s*\w+\s*=>/.test(filter) ? filter : `z => ${filter}`;
linqExpression += `.Where(${whereExpr})`;
}
linqExpression += `.OrderBy(z => z.Id)`;
const result = await apiService.executeQuery(linqExpression, {
take: limit || undefined,
select: expression !== 'z => z' ? expression : undefined,
});
return {
content: [
{
type: 'text',
text: JSON.stringify(
{
success: true,
entityType: entity_type,
result,
},
null,
2
),
},
],
};
} catch (err) {
return {
content: [
{
type: 'text',
text: JSON.stringify(
{
success: false,
error: err.message,
},
null,
2
),
},
],
isError: true,
};
}
}
/**
* Tool: execute_command
*/
async function executeCommand(args) {
const { command_name, properties } = args;
try {
const result = await apiService.executeCommand(command_name, properties);
return {
content: [
{
type: 'text',
text: JSON.stringify(
{
success: true,
command: command_name,
result,
},
null,
2
),
},
],
};
} catch (err) {
return {
content: [
{
type: 'text',
text: JSON.stringify(
{
success: false,
error: err.message,
},
null,
2
),
},
],
isError: true,
};
}
}
module.exports = {
listTools,
executeTool,
};
+151
View File
@@ -0,0 +1,151 @@
/**
* Config Tools
* - get_system_parameters : read WMS configuration parameters and their
* per-warehouse values.
*
* The Reading model exposes configuration as two entities:
* - Parameter : the parameter definition + DefaultValue
* - ParamValue : per-warehouse overrides, linked to Parameter by ParameterId
*
* This tool fetches both (small datasets — a few hundred rows), merges them
* client-side, and reports the effective value for the requested warehouse.
*/
const apiService = require('../services/api-service').getInstance();
function listTools() {
return [
{
name: 'get_system_parameters',
description: `Read WMS configuration parameters (entité Parameter) and their per-warehouse
overrides (entité ParamValue). Returns, for each parameter, the effective value
for the requested warehouse — the warehouse override if one exists, otherwise the
default value.
Use this instead of a raw LINQ query — the "CommandParameterData" entity does NOT
exist in the Reading model; the correct entities are Parameter + ParamValue.
Examples:
- get_system_parameters(warehouse="DOMBASLE") — all parameters with DOMBASLE values
- get_system_parameters(warehouse="DOMBASLE", param_class="Shipping")
- get_system_parameters(search="CROSSDOCK") — parameters whose code/description matches`,
inputSchema: {
type: 'object',
properties: {
warehouse: {
type: 'string',
description: 'Warehouse code to resolve effective values for (e.g. "DOMBASLE"). If omitted, all warehouse overrides are listed without picking an effective value.',
},
param_class: {
type: 'string',
description: 'Optional filter on ParamClass (e.g. "Shipping", "Reception", "Putaway").',
},
search: {
type: 'string',
description: 'Optional case-insensitive keyword matched against parameter Code and Description.',
},
only_overridden: {
type: 'boolean',
description: 'If true, return only parameters that have at least one warehouse override (default: false).',
default: false,
},
},
},
},
];
}
async function executeTool(name, args) {
switch (name) {
case 'get_system_parameters':
return await getSystemParameters(args);
default:
throw new Error(`Unknown config tool: ${name}`);
}
}
async function getSystemParameters(args) {
const { warehouse, param_class, search, only_overridden = false } = args || {};
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.
const [parameters, paramValues] = await Promise.all([
apiService.executeQuery('Context.Parameters.OrderBy(z => z.Id)', { take: 1000 }),
apiService.executeQuery('Context.ParamValues.OrderBy(z => z.Id)', { take: 1000 }),
]);
// Index overrides by ParameterId
const valuesByParam = {};
for (const v of paramValues || []) {
const pid = v.ParameterId;
if (!pid) continue;
(valuesByParam[pid] = valuesByParam[pid] || []).push(v);
}
const needle = search ? String(search).toLowerCase() : null;
let rows = (parameters || [])
.filter(p => {
if (param_class && p.ParamClass !== param_class) return false;
if (needle) {
const hay = `${p.Code || ''} ${p.Description || ''}`.toLowerCase();
if (!hay.includes(needle)) return false;
}
return true;
})
.map(p => {
const overrides = (valuesByParam[p.Id] || []).map(v => ({
warehouse: v.WarehouseCode,
value: v.Value,
}));
const whOverride = warehouse
? overrides.find(o => o.warehouse === warehouse)
: null;
return {
code: p.Code,
type: p.ParamType,
class: p.ParamClass,
scope: p.ParamScope,
isSystemParameter: p.IsSystemParameter,
isActive: p.IsActive,
description: p.Description,
defaultValue: p.DefaultValue,
effectiveValue: whOverride ? whOverride.value : p.DefaultValue,
overriddenForWarehouse: !!whOverride,
warehouseOverrides: overrides,
};
});
if (only_overridden) {
rows = rows.filter(r => r.warehouseOverrides.length > 0);
}
rows.sort((a, b) => String(a.code).localeCompare(String(b.code)));
return {
content: [{
type: 'text',
text: JSON.stringify({
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,
totalOverrides: Array.isArray(paramValues) ? paramValues.length : 0,
returned: rows.length,
parameters: rows,
}, null, 2),
}],
};
} catch (err) {
return {
content: [{
type: 'text',
text: JSON.stringify({ success: false, error: err.message, tool: 'get_system_parameters' }, null, 2),
}],
isError: true,
};
}
}
module.exports = { listTools, executeTool };
+216
View File
@@ -0,0 +1,216 @@
const logService = require('../services/log-service');
/**
* Tools MCP pour interagir avec les logs
*/
/**
* Liste des tools disponibles
* @returns {Array} - Liste des tools
*/
function listTools() {
return [
{
name: 'read_recent_logs',
description: 'Lit les dernières lignes des fichiers de logs',
inputSchema: {
type: 'object',
properties: {
count: {
type: 'number',
description: 'Nombre de lignes à lire (défaut: 100)',
default: 100,
},
log_file: {
type: 'string',
description: 'Nom du fichier de log spécifique (optionnel, prend le plus récent par défaut)',
},
},
},
},
{
name: 'list_log_files',
description: 'Liste tous les fichiers de logs disponibles sous LOGS_PATH avec leur taille et date de modification',
inputSchema: {
type: 'object',
properties: {},
},
},
{
name: 'search_logs',
description: 'Recherche un mot-clé dans les fichiers de logs avec contexte',
inputSchema: {
type: 'object',
properties: {
keyword: {
type: 'string',
description: 'Mot-clé à rechercher',
},
max_results: {
type: 'number',
description: 'Nombre maximum de résultats (défaut: 50)',
default: 50,
},
context_lines: {
type: 'number',
description: 'Nombre de lignes de contexte avant/après (défaut: 2)',
default: 2,
},
},
required: ['keyword'],
},
},
];
}
/**
* Exécute un tool log
* @param {string} name - Nom du tool
* @param {Object} args - Arguments du tool
* @returns {Promise<Object>} - Résultat du tool
*/
async function executeTool(name, args) {
switch (name) {
case 'list_log_files':
return await listLogFiles(args);
case 'read_recent_logs':
return await readRecentLogs(args);
case 'search_logs':
return await searchLogs(args);
default:
throw new Error(`Unknown log tool: ${name}`);
}
}
/**
* Tool: list_log_files
*/
async function listLogFiles() {
try {
const files = await logService.listLogFiles();
return {
content: [{
type: 'text',
text: JSON.stringify({
success: true,
totalFiles: files.length,
files: files.map(f => ({
name: f.name,
relativePath: f.relativePath,
sizeMB: (f.size / (1024 * 1024)).toFixed(2),
modified: f.modified.toISOString(),
})),
}, null, 2),
}],
};
} catch (err) {
return {
content: [{
type: 'text',
text: JSON.stringify({ success: false, error: err.message }, null, 2),
}],
isError: true,
};
}
}
/**
* Tool: read_recent_logs
*/
async function readRecentLogs(args) {
const { count = 100, log_file } = args;
try {
const result = await logService.readRecentLogs(count, log_file);
return {
content: [
{
type: 'text',
text: JSON.stringify(
{
success: true,
file: result.file,
totalLines: result.totalLines,
returnedLines: result.returnedLines,
lines: result.lines,
},
null,
2
),
},
],
};
} catch (err) {
return {
content: [
{
type: 'text',
text: JSON.stringify(
{
success: false,
error: err.message,
},
null,
2
),
},
],
isError: true,
};
}
}
/**
* Tool: search_logs
*/
async function searchLogs(args) {
const { keyword, max_results = 50, context_lines = 2 } = args;
try {
const result = await logService.searchLogs(keyword, max_results, context_lines);
return {
content: [
{
type: 'text',
text: JSON.stringify(
{
success: true,
keyword: result.keyword,
totalResults: result.totalResults,
results: result.results,
},
null,
2
),
},
],
};
} catch (err) {
return {
content: [
{
type: 'text',
text: JSON.stringify(
{
success: false,
error: err.message,
},
null,
2
),
},
],
isError: true,
};
}
}
module.exports = {
listTools,
executeTool,
};
+201
View File
@@ -0,0 +1,201 @@
/**
* Metadata & GenericSearch Tools
* - get_entity_metadata : list queryable entities and their properties (Metadata API)
* - generic_search : full-text search across indexed WMS documents (GenericSearch API)
*/
const apiService = require('../services/api-service').getInstance();
function listTools() {
return [
{
name: 'get_entity_metadata',
description: `Get metadata for WMS entities — field names, types, and assembly info — without querying actual data.
Use this to discover the exact field names and types for any entity before building a filter.
- Without entity_name: lists all queryable entities
- With entity_name (partial match ok, case-insensitive): returns field names + types for that entity`,
inputSchema: {
type: 'object',
properties: {
entity_name: {
type: 'string',
description: 'Partial entity name to look up (e.g. "OutboundOrder", "Task", "Stock"). Omit to list all entities.',
},
},
},
},
{
name: 'generic_search',
description: `Full-text search across indexed WMS documents (faster than LINQ for keyword searches).
First call without arguments to get available categories, then search within them.
Examples:
- generic_search(query="ORD-2024-001") — find an order by code
- generic_search(query="picking error", categories=["Tasks"]) — search tasks
- generic_search() — list available search categories`,
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'Search keyword or phrase',
},
categories: {
type: 'array',
items: { type: 'string' },
description: 'Document categories to search (omit to search all). Get available categories by calling without query.',
},
limit: {
type: 'number',
description: 'Max results (default: 20)',
default: 20,
},
skip: {
type: 'number',
description: 'Offset for pagination (default: 0)',
default: 0,
},
},
},
},
];
}
async function executeTool(name, args) {
switch (name) {
case 'get_entity_metadata':
return await getEntityMetadata(args);
case 'generic_search':
return await genericSearch(args);
default:
throw new Error(`Unknown metadata tool: ${name}`);
}
}
async function getEntityMetadata(args) {
const { entity_name } = args || {};
try {
const entities = await apiService.getMetadataEntities();
if (!entity_name) {
// Return full entity list
return {
content: [{
type: 'text',
text: JSON.stringify({
success: true,
totalEntities: Array.isArray(entities) ? entities.length : '?',
entities,
}, null, 2),
}],
};
}
// Find matching entities (case-insensitive partial match)
const search = entity_name.toLowerCase();
const matches = Array.isArray(entities)
? entities.filter(e => {
const name = (e.FullName || e.Name || e.fullName || e.name || JSON.stringify(e)).toLowerCase();
return name.includes(search);
})
: [];
if (matches.length === 0) {
return {
content: [{
type: 'text',
text: JSON.stringify({
success: false,
error: `No entity matching "${entity_name}" found`,
availableCount: Array.isArray(entities) ? entities.length : '?',
hint: 'Call get_entity_metadata without entity_name to see all entities',
}, null, 2),
}],
isError: true,
};
}
// Fetch properties for each match (up to 3)
const results = [];
for (const entity of matches.slice(0, 3)) {
const assemblyFullName = entity.AssemblyFullName || entity.assemblyFullName;
const fullName = entity.FullName || entity.fullName;
let properties = null;
if (assemblyFullName && fullName) {
try {
properties = await apiService.getEntityProperties(assemblyFullName, fullName);
} catch (err) {
console.error(`[Metadata] Could not fetch properties for ${fullName}: ${err.message}`);
}
}
results.push({ entity, properties });
}
return {
content: [{
type: 'text',
text: JSON.stringify({
success: true,
query: entity_name,
matchCount: matches.length,
results,
}, null, 2),
}],
};
} catch (err) {
return {
content: [{
type: 'text',
text: JSON.stringify({ success: false, error: err.message }, null, 2),
}],
isError: true,
};
}
}
async function genericSearch(args) {
const { query, categories = [], limit = 20, skip = 0 } = args || {};
try {
// No query: return available categories
if (!query) {
const cats = await apiService.getSearchCategories();
return {
content: [{
type: 'text',
text: JSON.stringify({
success: true,
availableCategories: cats,
hint: 'Pass query + optional categories[] to search',
}, null, 2),
}],
};
}
const result = await apiService.genericSearch(query, categories, limit, skip);
return {
content: [{
type: 'text',
text: JSON.stringify({
success: true,
query,
categories: categories.length > 0 ? categories : 'all',
result,
}, null, 2),
}],
};
} catch (err) {
return {
content: [{
type: 'text',
text: JSON.stringify({ success: false, error: err.message }, null, 2),
}],
isError: true,
};
}
}
module.exports = { listTools, executeTool };
+164
View File
@@ -0,0 +1,164 @@
/**
* Profile Management Tools
* Let Claude list, inspect, and switch the active WMS profile at runtime.
*
* On switch: api-service resets its OAuth token, workflow-service/ad-service
* clear their caches. The next API call re-authenticates against the new host
* with the new tenant/credentials.
*/
const profileManager = require('../config/profile-manager');
function listTools() {
return [
{
name: 'list_wms_profiles',
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: {
type: 'object',
properties: {},
},
},
{
name: 'get_current_wms_profile',
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: {
type: 'object',
properties: {},
},
},
{
name: 'switch_wms_profile',
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: {
type: 'object',
properties: {
profile: {
type: 'string',
description: 'Profile name (case-sensitive), e.g. "AD" or "LIMAGRAIN".',
},
},
required: ['profile'],
},
},
];
}
async function executeTool(name, args) {
switch (name) {
case 'list_wms_profiles':
return listProfilesTool();
case 'get_current_wms_profile':
return getCurrentProfileTool();
case 'switch_wms_profile':
return switchProfileTool(args);
default:
throw new Error(`Unknown profile tool: ${name}`);
}
}
function summarize(profile) {
return {
name: profile.name,
host: profile.host,
tenant: profile.tenant,
username: profile.username,
application: profile.application,
saas: profile.saas,
logsAvailable: !profile.saas,
apiBaseUrl: profile.apiBaseUrl,
tokenUrl: profile.tokenUrl,
workflowApiBase: profile.workflowApiBase,
};
}
function listProfilesTool() {
const names = profileManager.listProfiles();
const current = profileManager.getCurrentName();
return {
content: [{
type: 'text',
text: JSON.stringify({
success: true,
current,
profiles: names,
hint: current
? `Active profile: ${current}. Call switch_wms_profile to change.`
: 'No profile active. Call switch_wms_profile with one of the names above before any WMS API call.',
}, null, 2),
}],
};
}
function getCurrentProfileTool() {
try {
const profile = profileManager.getCurrent();
return {
content: [{
type: 'text',
text: JSON.stringify({ success: true, profile: summarize(profile) }, null, 2),
}],
};
} catch (err) {
return {
content: [{
type: 'text',
text: JSON.stringify({
success: false,
error: err.message,
profiles: profileManager.listProfiles(),
}, null, 2),
}],
isError: true,
};
}
}
function switchProfileTool(args) {
const { profile } = args || {};
if (!profile) {
return {
content: [{
type: 'text',
text: JSON.stringify({
success: false,
error: 'Missing "profile" argument',
profiles: profileManager.listProfiles(),
}, null, 2),
}],
isError: true,
};
}
try {
const previous = profileManager.getCurrentName();
const next = profileManager.switchTo(profile);
return {
content: [{
type: 'text',
text: JSON.stringify({
success: true,
previous,
current: next.name,
profile: summarize(next),
note: 'OAuth token and workflow/AD caches have been cleared. Next API call will re-authenticate.',
}, null, 2),
}],
};
} catch (err) {
return {
content: [{
type: 'text',
text: JSON.stringify({
success: false,
error: err.message,
profiles: profileManager.listProfiles(),
}, null, 2),
}],
isError: true,
};
}
}
module.exports = { listTools, executeTool };
+275
View File
@@ -0,0 +1,275 @@
/**
* WMS Query Tools
* MCP tools for querying WMS entities via LINQ expressions
*/
const wmsQueryService = require('../services/wms-query-service');
/**
* List available WMS query tools
*/
function listTools() {
return [
{
name: 'query_wms_entities',
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).
Common entities: Products, Containers, Tasks, Stocks, ProductLocations, Location, InboundOrders, OutboundOrders, Receptions, Accounts, Suppliers, Kits, Aliases.
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)
2. If no doc found: call get_ad_elements(element_type="Entity", search=entity_name)
3. Last resort: call get_entity_metadata(entity_name=...) to get field names
Never guess enum string values — they differ between Reading and Writing models.`,
inputSchema: {
type: 'object',
properties: {
entity_type: {
type: 'string',
description: 'Entity type (Products, Containers, Tasks, Stocks, ProductLocations, InboundOrders, OutboundOrders, Accounts, Suppliers, Kits, Aliases, Receptions)',
},
select_expression: {
type: 'string',
description: 'LINQ select expression (default: "z => z" for all fields, or "z => new { z.Id, z.Name }" for specific fields)',
default: 'z => z',
},
filter: {
type: 'string',
description: 'Optional LINQ filter (e.g., "z => z.Id > 100" or "z => z.Status == \'Active\'")',
},
limit: {
type: 'number',
description: 'Maximum results to return (default: 100, max: 1000)',
default: 100,
},
},
required: ['entity_type'],
},
},
{
name: 'get_entity_schema',
description: 'Get the schema/structure of a WMS entity by querying one sample record',
inputSchema: {
type: 'object',
properties: {
entity_type: {
type: 'string',
description: 'Entity type to inspect',
},
},
required: ['entity_type'],
},
},
{
name: 'count_wms_entities',
description: `PREFERRED TOOL for counting ("combien", "nombre de", "how many"). Uses QueryScalarExecute → Reading model. Never adds Take(), safe on all entity types.
IMPORTANT: Always use this tool first for any counting question — do not use query_wms_entities for counts.
IMPORTANT — enum/status values MUST be verified before use. Reading model uses string enum names, NOT integers.
Lookup order for enum values:
1. Read the relevant doc: docs://entities/ (e.g. easywms_reading_entites_outboundorder_OutboundOrderStatus for OutboundOrders)
2. If no doc: call get_ad_elements(element_type="Entity", search=entity_name)
3. Last resort: call get_entity_metadata(entity_name=...) — gives field names but not all enum values
Never hardcode enum values without checking — Reading vs Writing model distinction has caused bugs before.
Verified values (curl-tested):
- Tasks actives: filter='z.TaskStatus == "InProcess" || z.TaskStatus == "Pending"'
- Tasks en cours: filter='z.TaskStatus == "InProcess"'
- Tasks en attente: filter='z.TaskStatus == "Pending"'
- ODS lancés: filter='z.OutboundOrderStatus == "Release"'
- OS incomplètes actives: filter='z.IncompleteOrder == true && z.IsActive == true'
(par classe: ajouter && z.OutboundClassCode == "X")
- Containers bloquants: filter='z.NumContainerPendingTasks > 0'
(sur un emplacement: ajouter && z.LocationCode == "X")`,
inputSchema: {
type: 'object',
properties: {
entity_type: {
type: 'string',
description: 'Entity type (Products, Containers, Tasks, Stocks, OutboundOrders, InboundOrders, etc.)',
},
filter: {
type: 'string',
description: 'Optional LINQ filter condition. Status fields are strings (enum names from Reading model). Always verify enum values via docs://entities/ before use.',
},
},
required: ['entity_type'],
},
},
{
name: 'search_wms_data',
description: 'Search for a keyword across multiple WMS entities',
inputSchema: {
type: 'object',
properties: {
keyword: {
type: 'string',
description: 'Keyword to search for',
},
entity_types: {
type: 'array',
items: { type: 'string' },
description: 'List of entity types to search in (default: Products, Containers, Tasks)',
},
limit: {
type: 'number',
description: 'Maximum results per entity (default: 50)',
default: 50,
},
},
required: ['keyword'],
},
},
];
}
/**
* Execute WMS query tool
*/
async function executeTool(name, args) {
try {
switch (name) {
case 'query_wms_entities':
return await queryWmsEntities(args);
case 'count_wms_entities':
return await countWmsEntities(args);
case 'get_entity_schema':
return await getEntitySchema(args);
case 'search_wms_data':
return await searchWmsData(args);
default:
throw new Error(`Unknown WMS query tool: ${name}`);
}
} catch (error) {
console.error(`[WMSQueryTools] Error executing ${name}:`, error.message);
return {
content: [{
type: 'text',
text: JSON.stringify({
success: false,
error: error.message,
tool: name
}, null, 2)
}],
isError: true
};
}
}
/**
* Tool: query_wms_entities
*/
async function queryWmsEntities(args) {
const { entity_type, select_expression = 'z => z', filter, limit = 100 } = args;
console.error(`[WMSQueryTools] Querying ${entity_type}: limit=${limit}`);
const result = await wmsQueryService.queryEntities(
entity_type,
select_expression,
filter,
limit
);
return {
content: [{
type: 'text',
text: JSON.stringify({
success: true,
...result
}, null, 2)
}]
};
}
/**
* Tool: count_wms_entities
*/
async function countWmsEntities(args) {
const { entity_type, filter } = args;
console.error(`[WMSQueryTools] Counting ${entity_type}${filter ? ` where ${filter}` : ''}`);
const result = await wmsQueryService.countEntities(entity_type, filter || null);
return {
content: [{
type: 'text',
text: JSON.stringify({
success: true,
...result
}, null, 2)
}]
};
}
/**
* Tool: get_entity_schema
*/
async function getEntitySchema(args) {
const { entity_type } = args;
console.error(`[WMSQueryTools] Getting schema for ${entity_type}`);
const result = await wmsQueryService.getEntitySchema(entity_type);
return {
content: [{
type: 'text',
text: JSON.stringify({
success: true,
...result
}, null, 2)
}]
};
}
/**
* Tool: search_wms_data
*/
async function searchWmsData(args) {
const { keyword, entity_types = ['Products', 'Containers', 'Tasks'], limit = 50 } = args;
console.error(`[WMSQueryTools] Searching for "${keyword}" in ${entity_types.join(', ')}`);
const results = {};
let totalFound = 0;
for (const entityType of entity_types) {
try {
const result = await wmsQueryService.searchEntities(entityType, keyword, limit);
results[entityType] = {
count: result.count,
data: result.data
};
totalFound += result.count;
} catch (error) {
console.error(`[WMSQueryTools] Search failed for ${entityType}:`, error.message);
results[entityType] = {
error: error.message,
count: 0
};
}
}
return {
content: [{
type: 'text',
text: JSON.stringify({
success: true,
keyword,
totalFound,
results
}, null, 2)
}]
};
}
module.exports = {
listTools,
executeTool,
};
+175
View File
@@ -0,0 +1,175 @@
/**
* Workflow Tools
* MCP tools for searching and retrieving workflow information via API
*/
const workflowService = require('../services/workflow-service');
/**
* List available workflow tools
*/
function listTools() {
return [
{
name: 'search_workflows',
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: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'Search query (searches in name, description, code)',
},
category: {
type: 'string',
description: 'Filter by workflow category/application',
},
limit: {
type: 'number',
description: 'Maximum results to return (default: 50)',
default: 50,
},
},
},
},
{
name: 'get_workflow_details',
description: 'Get full details of a specific workflow by ID or code',
inputSchema: {
type: 'object',
properties: {
workflow_id: {
type: 'string',
description: 'Workflow ID or code',
},
},
required: ['workflow_id'],
},
},
{
name: 'list_workflow_categories',
description: 'List all available workflow categories',
inputSchema: {
type: 'object',
properties: {},
},
},
];
}
/**
* 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 } = args;
console.error(`[WorkflowTools] Searching workflows: query="${query}", category="${category}", limit=${limit}`);
const results = await workflowService.searchWorkflows(query, category, limit);
return {
content: [{
type: 'text',
text: JSON.stringify({
success: true,
count: results.length,
workflows: results.map(w => ({
id: w.Id,
code: w.Code,
name: w.Name,
category: w.Category,
description: w.Description,
version: w.Version,
created: w.Created,
modified: w.Modified
}))
}, null, 2)
}]
};
}
/**
* Tool: get_workflow_details
*/
async function getWorkflowDetails(args) {
const { workflow_id } = args;
console.error(`[WorkflowTools] Getting workflow details: ${workflow_id}`);
const workflow = await workflowService.getWorkflowDetails(workflow_id);
return {
content: [{
type: 'text',
text: JSON.stringify({
success: true,
workflow
}, null, 2)
}]
};
}
/**
* Tool: list_workflow_categories
*/
async function listWorkflowCategories(args) {
console.error('[WorkflowTools] Listing workflow categories');
const categories = await workflowService.listWorkflowCategories();
const stats = await workflowService.getWorkflowStats();
return {
content: [{
type: 'text',
text: JSON.stringify({
success: true,
totalCategories: categories.length,
categories,
stats: {
totalWorkflows: stats.total,
categoryCounts: stats.categoryCounts,
cacheAge: stats.cacheAge
}
}, null, 2)
}]
};
}
module.exports = {
listTools,
executeTool,
};