5386f54922
Mesure V1 (24/08/2026) : le SDK MCP ignore additionalProperties: false —
read_recent_logs({lines: 5}) avec la clause sur le schéma répondait
success: true, returnedLines: 100 (retombée silencieuse sur le défaut).
La validation vit donc dans le wrapper tools/call de src/index.js,
pilotée par les schémas de la table de routage (D22) : paramètre inconnu
ou requis manquant -> erreur structurée nommant le fautif et les
paramètres valides, avant tout dispatch.
Les 23 schémas portent additionalProperties: false — inerte côté SDK,
mais c'est le contrat que lisent les clients. Pas de renommage de
paramètres (écarté, cf. ROADMAP).
Mesures (via le protocole) :
- read_recent_logs({"lines": 5}) -> "Paramètre(s) inconnu(s) pour
read_recent_logs : lines. Paramètres valides : count, log_file."
- read_recent_logs({"count": 5}) -> succès, returnedLines: 5
- boucle sur 22 outils avec {} (execute_command vérifié statiquement) :
22/22 répondent, aucun Unknown tool, les 11 outils à paramètres requis
échouent avec le message actionnable
- handshake : 23 outils, 6 resources
Docs : D23 dans DECISIONS.md, L2.2 retirée de la ROADMAP.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
321 lines
10 KiB
JavaScript
321 lines
10 KiB
JavaScript
#!/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);
|
|
|
|
// Resolve .env:
|
|
// - packaged (.exe built with pkg): next to the executable, so the deployed
|
|
// server can be reconfigured without a rebuild and no credential is ever
|
|
// baked into the binary snapshot.
|
|
// - from sources: project root.
|
|
const ENV_PATH = process.pkg
|
|
? path.join(path.dirname(process.execPath), '.env')
|
|
: path.join(__dirname, '..', '.env');
|
|
|
|
require('dotenv').config({ path: ENV_PATH });
|
|
|
|
// 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');
|
|
|
|
const TOOL_MODULES = [
|
|
{ moduleName: 'workflow-tools', module: workflowTools },
|
|
{ moduleName: 'wms-query-tools', module: wmsQueryTools },
|
|
{ moduleName: 'api-tools', module: apiTools },
|
|
{ moduleName: 'log-tools', module: logTools },
|
|
{ moduleName: 'ad-tools', module: adTools },
|
|
{ moduleName: 'metadata-tools', module: metadataTools },
|
|
{ moduleName: 'config-tools', module: configTools },
|
|
{ moduleName: 'profile-tools', module: profileTools },
|
|
];
|
|
|
|
// Table explicite nom d'outil -> module, construite depuis les listTools() de
|
|
// chaque module : un outil listé est un outil routé, par construction. Le
|
|
// routage par préfixe de nom laissait des outils listés mais injoignables
|
|
// (get_entity_metadata capté par la mauvaise branche, list_log_files capté
|
|
// par aucune).
|
|
// Un nom déclaré par deux modules est un bug de développement : on échoue au
|
|
// démarrage, pas à l'exécution.
|
|
const toolRegistry = new Map();
|
|
for (const { moduleName, module } of TOOL_MODULES) {
|
|
for (const definition of module.listTools()) {
|
|
const existing = toolRegistry.get(definition.name);
|
|
if (existing) {
|
|
throw new Error(
|
|
`[Server] Duplicate tool name "${definition.name}" declared by both ` +
|
|
`${existing.moduleName} and ${moduleName} — rename one of them`
|
|
);
|
|
}
|
|
toolRegistry.set(definition.name, { moduleName, module, definition });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Valide les arguments d'un appel d'outil contre son inputSchema (D23).
|
|
* Le SDK MCP ne valide pas les schémas d'entrée — mesuré le 24/08/2026 :
|
|
* `additionalProperties: false` est ignoré et un paramètre inconnu retombe
|
|
* silencieusement sur les défauts. La validation vit donc ici, pilotée par la
|
|
* même table que tools/list : schéma déclaré = contrat appliqué.
|
|
*/
|
|
function validateToolArgs(definition, args) {
|
|
const schema = definition.inputSchema || {};
|
|
const properties = schema.properties || {};
|
|
const validNames = Object.keys(properties);
|
|
const validList = validNames.length > 0 ? validNames.join(', ') : '(aucun)';
|
|
|
|
const unknown = Object.keys(args || {}).filter(key => !(key in properties));
|
|
if (unknown.length > 0) {
|
|
throw new Error(
|
|
`Paramètre(s) inconnu(s) pour ${definition.name} : ${unknown.join(', ')}. ` +
|
|
`Paramètres valides : ${validList}.`
|
|
);
|
|
}
|
|
|
|
const missing = (schema.required || []).filter(key => args?.[key] === undefined);
|
|
if (missing.length > 0) {
|
|
throw new Error(
|
|
`Paramètre(s) requis manquant(s) pour ${definition.name} : ${missing.join(', ')}. ` +
|
|
`Paramètres valides : ${validList}.`
|
|
);
|
|
}
|
|
}
|
|
|
|
// Create MCP Server
|
|
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 () => {
|
|
// Servi depuis la table de routage : la liste exposée et le dispatch ne
|
|
// peuvent pas diverger.
|
|
return {
|
|
tools: Array.from(toolRegistry.values(), entry => entry.definition),
|
|
};
|
|
});
|
|
|
|
/**
|
|
* 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}`);
|
|
|
|
const entry = toolRegistry.get(name);
|
|
if (!entry) {
|
|
throw new Error(
|
|
`Unknown tool: ${name}. Available tools: ${Array.from(toolRegistry.keys()).join(', ')}`
|
|
);
|
|
}
|
|
validateToolArgs(entry.definition, args);
|
|
return await entry.module.executeTool(name, args);
|
|
} 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();
|