From: Jérôme Benoit Date: Tue, 24 Mar 2026 00:58:12 +0000 (+0100) Subject: refactor(mcp): split MCP definitions by spec concern — Resources, Tools, ToolSchemas X-Git-Tag: ocpp-server@v3.3.0~2 X-Git-Url: https://git.piment-noir.org/?a=commitdiff_plain;h=9d738e3408daddd27d2182d7416dd22eb38c0cea;p=e-mobility-charging-stations-simulator.git refactor(mcp): split MCP definitions by spec concern — Resources, Tools, ToolSchemas Rename MCPResourceHandlers.ts to MCPResources.ts (contains only MCP Resources: station-list, station-by-id, template-list, OCPP schemas). Extract log tools (readCombinedLog, readErrorLog) and their helpers (getLogFilePath, tailFile) into MCPTools.ts (MCP Tools per spec). MCPToolSchemas.ts unchanged (tool schema definitions). Directory now reflects MCP spec separation of concerns: - MCPResources.ts — MCP Resources (spec §Resources) - MCPTools.ts — MCP Tools with custom logic (spec §Tools) - MCPToolSchemas.ts — Tool schema definitions for OCPP commands --- diff --git a/src/charging-station/ui-server/mcp/MCPResourceHandlers.ts b/src/charging-station/ui-server/mcp/MCPResources.ts similarity index 59% rename from src/charging-station/ui-server/mcp/MCPResourceHandlers.ts rename to src/charging-station/ui-server/mcp/MCPResources.ts index ec134516..516f68ea 100644 --- a/src/charging-station/ui-server/mcp/MCPResourceHandlers.ts +++ b/src/charging-station/ui-server/mcp/MCPResources.ts @@ -1,60 +1,11 @@ import { type McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js' -import { open, readdir, readFile, stat } from 'node:fs/promises' +import { readdir, readFile } from 'node:fs/promises' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import { z } from 'zod' import type { AbstractUIServer } from '../AbstractUIServer.js' -import { ConfigurationSection, type LogConfiguration, OCPPVersion } from '../../../types/index.js' -import { Configuration } from '../../../utils/Configuration.js' - -const MAX_TAIL_LINES = 5000 -const DEFAULT_TAIL_LINES = 200 -const TAIL_BYTES = 65_536 - -const getLogFilePath = (configField: 'errorFile' | 'file', date?: string): string | undefined => { - const logConfig = Configuration.getConfigurationSection( - ConfigurationSection.log - ) - const relativePath = logConfig[configField] - if (relativePath == null) { - return undefined - } - if (logConfig.rotate !== true) { - return resolve(relativePath) - } - const now = new Date() - const localDate = - date ?? - `${now.getFullYear().toString()}-${(now.getMonth() + 1).toString().padStart(2, '0')}-${now.getDate().toString().padStart(2, '0')}` - const dir = dirname(resolve(relativePath)) - const baseName = configField === 'file' ? `combined-${localDate}.log` : `error-${localDate}.log` - return join(dir, baseName) -} - -const tailFile = async ( - filePath: string, - maxLines: number -): Promise<{ lines: string[]; totalLines: number }> => { - const fileStat = await stat(filePath) - const fileHandle = await open(filePath, 'r') - try { - const fullContent = fileStat.size <= TAIL_BYTES - const readSize = Math.min(TAIL_BYTES, fileStat.size) - const position = Math.max(0, fileStat.size - readSize) - const buffer = Buffer.alloc(readSize) - await fileHandle.read(buffer, 0, readSize, position) - const allLines = buffer.toString('utf8').split('\n') - if (position > 0) { - allLines.shift() - } - const totalLines = fullContent ? allLines.length : -1 - return { lines: allLines.slice(-maxLines), totalLines } - } finally { - await fileHandle.close() - } -} +import { OCPPVersion } from '../../../types/index.js' export const registerMCPResources = (server: McpServer, uiServer: AbstractUIServer): void => { server.registerResource( @@ -238,72 +189,3 @@ export const registerMCPSchemaResources = (server: McpServer): void => { } ) } - -const registerLogReadTool = ( - server: McpServer, - name: string, - configField: 'errorFile' | 'file', - description: string -): void => { - const label = configField === 'file' ? 'Log' : 'Error log' - server.registerTool( - name, - { - annotations: { readOnlyHint: true }, - description, - inputSchema: { - date: z - .string() - .regex(/^\d{4}-\d{2}-\d{2}$/) - .optional() - .describe('Log file date in YYYY-MM-DD format. Defaults to current local date'), - tail: z - .number() - .int() - .positive() - .max(MAX_TAIL_LINES) - .default(DEFAULT_TAIL_LINES) - .describe('Number of lines to return from the end of the log'), - }, - }, - async ({ date, tail }) => { - try { - const logPath = getLogFilePath(configField, date) - if (logPath == null) { - return { - content: [{ text: `${label} file not configured`, type: 'text' as const }], - isError: true, - } - } - const { lines, totalLines } = await tailFile(logPath, tail) - const meta = - totalLines >= 0 - ? `Showing last ${String(lines.length)} of ${String(totalLines)} lines` - : `Showing last ${String(lines.length)} lines` - return { - content: [{ text: `${meta}\n\n${lines.join('\n')}`, type: 'text' as const }], - } - } catch { - return { - content: [{ text: `${label} file not available`, type: 'text' as const }], - isError: true, - } - } - } - ) -} - -export const registerMCPLogTools = (server: McpServer): void => { - registerLogReadTool( - server, - 'readCombinedLog', - 'file', - 'Read recent entries from the combined simulator log file. Returns the last N lines (default 200, max 5000). Optionally specify a date (YYYY-MM-DD) for rotated log files.' - ) - registerLogReadTool( - server, - 'readErrorLog', - 'errorFile', - 'Read recent entries from the error log file. Returns the last N lines (default 200, max 5000). Optionally specify a date (YYYY-MM-DD) for rotated log files.' - ) -} diff --git a/src/charging-station/ui-server/mcp/MCPTools.ts b/src/charging-station/ui-server/mcp/MCPTools.ts new file mode 100644 index 00000000..22d5aef4 --- /dev/null +++ b/src/charging-station/ui-server/mcp/MCPTools.ts @@ -0,0 +1,124 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' + +import { open, stat } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { z } from 'zod' + +import { ConfigurationSection, type LogConfiguration } from '../../../types/index.js' +import { Configuration } from '../../../utils/Configuration.js' + +const MAX_TAIL_LINES = 5000 +const DEFAULT_TAIL_LINES = 200 +const TAIL_BYTES = 65_536 + +const getLogFilePath = (configField: 'errorFile' | 'file', date?: string): string | undefined => { + const logConfig = Configuration.getConfigurationSection( + ConfigurationSection.log + ) + const relativePath = logConfig[configField] + if (relativePath == null) { + return undefined + } + if (logConfig.rotate !== true) { + return resolve(relativePath) + } + const now = new Date() + const localDate = + date ?? + `${now.getFullYear().toString()}-${(now.getMonth() + 1).toString().padStart(2, '0')}-${now.getDate().toString().padStart(2, '0')}` + const dir = dirname(resolve(relativePath)) + const baseName = configField === 'file' ? `combined-${localDate}.log` : `error-${localDate}.log` + return join(dir, baseName) +} + +const tailFile = async ( + filePath: string, + maxLines: number +): Promise<{ lines: string[]; totalLines: number }> => { + const fileStat = await stat(filePath) + const fileHandle = await open(filePath, 'r') + try { + const fullContent = fileStat.size <= TAIL_BYTES + const readSize = Math.min(TAIL_BYTES, fileStat.size) + const position = Math.max(0, fileStat.size - readSize) + const buffer = Buffer.alloc(readSize) + await fileHandle.read(buffer, 0, readSize, position) + const allLines = buffer.toString('utf8').split('\n') + if (position > 0) { + allLines.shift() + } + const totalLines = fullContent ? allLines.length : -1 + return { lines: allLines.slice(-maxLines), totalLines } + } finally { + await fileHandle.close() + } +} + +const registerLogReadTool = ( + server: McpServer, + name: string, + configField: 'errorFile' | 'file', + description: string +): void => { + const label = configField === 'file' ? 'Log' : 'Error log' + server.registerTool( + name, + { + annotations: { readOnlyHint: true }, + description, + inputSchema: { + date: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional() + .describe('Log file date in YYYY-MM-DD format. Defaults to current local date'), + tail: z + .number() + .int() + .positive() + .max(MAX_TAIL_LINES) + .default(DEFAULT_TAIL_LINES) + .describe('Number of lines to return from the end of the log'), + }, + }, + async ({ date, tail }) => { + try { + const logPath = getLogFilePath(configField, date) + if (logPath == null) { + return { + content: [{ text: `${label} file not configured`, type: 'text' as const }], + isError: true, + } + } + const { lines, totalLines } = await tailFile(logPath, tail) + const meta = + totalLines >= 0 + ? `Showing last ${String(lines.length)} of ${String(totalLines)} lines` + : `Showing last ${String(lines.length)} lines` + return { + content: [{ text: `${meta}\n\n${lines.join('\n')}`, type: 'text' as const }], + } + } catch { + return { + content: [{ text: `${label} file not available`, type: 'text' as const }], + isError: true, + } + } + } + ) +} + +export const registerMCPLogTools = (server: McpServer): void => { + registerLogReadTool( + server, + 'readCombinedLog', + 'file', + 'Read recent entries from the combined simulator log file. Returns the last N lines (default 200, max 5000). Optionally specify a date (YYYY-MM-DD) for rotated log files.' + ) + registerLogReadTool( + server, + 'readErrorLog', + 'errorFile', + 'Read recent entries from the error log file. Returns the last N lines (default 200, max 5000). Optionally specify a date (YYYY-MM-DD) for rotated log files.' + ) +} diff --git a/src/charging-station/ui-server/mcp/index.ts b/src/charging-station/ui-server/mcp/index.ts index ad2469de..434a14de 100644 --- a/src/charging-station/ui-server/mcp/index.ts +++ b/src/charging-station/ui-server/mcp/index.ts @@ -1,7 +1,4 @@ -export { - registerMCPLogTools, - registerMCPResources, - registerMCPSchemaResources, -} from './MCPResourceHandlers.js' +export { registerMCPResources, registerMCPSchemaResources } from './MCPResources.js' +export { registerMCPLogTools } from './MCPTools.js' export { mcpToolSchemas, ocppSchemaMapping } from './MCPToolSchemas.js' export type { MCPToolSchema } from './MCPToolSchemas.js'