UNPKG

bc-webclient-mcp

Version:

Model Context Protocol (MCP) server for Microsoft Dynamics 365 Business Central via WebUI protocol. Enables AI assistants to interact with BC through the web client protocol, supporting Card, List, and Document pages with full line item support and server

452 lines 16.3 kB
/** * Stdio Transport for MCP Protocol * * Implements JSON-RPC 2.0 communication over stdio. * Handles reading requests from stdin and writing responses to stdout. * * Features: * - Line-by-line JSON-RPC message parsing * - Request routing to MCP server * - Response serialization * - Error handling * - Graceful shutdown */ import * as readline from 'node:readline'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { ok, err, isOk } from '../core/result.js'; import { InternalError } from '../core/errors.js'; import { toMCPError } from '../core/mcp-error-mapping.js'; // ============================================================================ // JSON-RPC Error Codes // ============================================================================ const JSON_RPC_ERROR_CODES = { PARSE_ERROR: -32700, INVALID_REQUEST: -32600, METHOD_NOT_FOUND: -32601, INVALID_PARAMS: -32602, INTERNAL_ERROR: -32603, }; /** * Stdio transport for MCP protocol. * * Reads JSON-RPC requests from stdin, routes them to the server, * and writes responses to stdout. */ export class StdioTransport { server; options; reader; running = false; stdoutClosed = false; stdioLogStream; messageCounter = 0; constructor(server, options = {}) { this.server = server; this.options = options; this.reader = readline.createInterface({ input: process.stdin, output: undefined, // Don't echo input terminal: false, }); // Initialize STDIO logging if enabled via environment variable this.initializeStdioLogging(); // Handle stdout errors (EPIPE when client disconnects) process.stdout.on('error', (error) => { if (error.code === 'EPIPE') { this.options.logger?.info('Client disconnected (EPIPE), marking stdout as closed'); this.stdoutClosed = true; void this.stop(); } else { this.options.logger?.error('Stdout error', error); } }); // Handle stdout close process.stdout.on('close', () => { this.options.logger?.info('Stdout closed'); this.stdoutClosed = true; void this.stop(); }); } /** * Initializes STDIO logging to file if MCP_STDIO_LOG_FILE environment variable is set. * @private */ initializeStdioLogging() { const logFile = process.env.MCP_STDIO_LOG_FILE; if (!logFile) { return; } try { // Ensure directory exists const logDir = path.dirname(logFile); if (!fs.existsSync(logDir)) { fs.mkdirSync(logDir, { recursive: true }); } // Create write stream (append mode) this.stdioLogStream = fs.createWriteStream(logFile, { flags: 'a' }); // Write session start marker const startTime = new Date().toISOString(); this.stdioLogStream.write(`\n${'='.repeat(80)}\n`); this.stdioLogStream.write(`MCP STDIO Session Started: ${startTime}\n`); this.stdioLogStream.write(`${'='.repeat(80)}\n\n`); this.options.logger?.info('STDIO logging enabled', { logFile }); } catch (error) { this.options.logger?.error('Failed to initialize STDIO logging', error, { logFile }); } } /** * Logs a message to the STDIO log file. * @private */ logStdioMessage(direction, message) { if (!this.stdioLogStream) { return; } try { this.messageCounter++; const timestamp = new Date().toISOString(); const messageStr = typeof message === 'string' ? message : JSON.stringify(message, null, 2); this.stdioLogStream.write(`[${this.messageCounter}] ${timestamp} ${direction}\n`); this.stdioLogStream.write(`${'-'.repeat(80)}\n`); this.stdioLogStream.write(`${messageStr}\n`); this.stdioLogStream.write(`\n`); } catch (error) { this.options.logger?.error('Failed to write STDIO log', error); } } /** * Starts the transport. * Begins listening for JSON-RPC requests on stdin. */ async start() { try { if (this.running) { return err(new InternalError('Transport already running', { code: 'TRANSPORT_ALREADY_RUNNING' })); } this.options.logger?.info('Starting stdio transport'); this.running = true; // Listen for lines from stdin this.reader.on('line', (line) => { void this.handleLine(line); }); // Handle stdin close this.reader.on('close', () => { this.options.logger?.info('Stdin closed, stopping transport'); void this.stop(); }); // Handle process signals process.on('SIGINT', () => { this.options.logger?.info('Received SIGINT, shutting down'); void this.stop(); process.exit(0); }); process.on('SIGTERM', () => { this.options.logger?.info('Received SIGTERM, shutting down'); void this.stop(); process.exit(0); }); this.options.logger?.info('Stdio transport started'); return ok(undefined); } catch (error) { return err(new InternalError('Failed to start stdio transport', { code: 'TRANSPORT_START_FAILED', error: String(error) })); } } /** * Stops the transport gracefully. */ async stop() { try { if (!this.running) { return ok(undefined); } this.options.logger?.info('Stopping stdio transport'); this.running = false; this.reader.close(); // Close STDIO log stream if open if (this.stdioLogStream) { try { const endTime = new Date().toISOString(); this.stdioLogStream.write(`\n${'='.repeat(80)}\n`); this.stdioLogStream.write(`MCP STDIO Session Ended: ${endTime}\n`); this.stdioLogStream.write(`Total Messages: ${this.messageCounter}\n`); this.stdioLogStream.write(`${'='.repeat(80)}\n\n`); this.stdioLogStream.end(); this.stdioLogStream = undefined; } catch (error) { this.options.logger?.error('Failed to close STDIO log stream', error); } } this.options.logger?.info('Stdio transport stopped'); return ok(undefined); } catch (error) { return err(new InternalError('Failed to stop stdio transport', { code: 'TRANSPORT_STOP_FAILED', error: String(error) })); } } /** * Handles a line from stdin. */ async handleLine(line) { if (!line.trim()) { return; // Skip empty lines } // Don't process requests if we're shutting down if (!this.running || this.stdoutClosed) { this.options.logger?.debug('Transport not running, skipping request'); return; } if (this.options.enableDebugLogging) { this.options.logger?.debug('Received request', { line }); } try { // Parse JSON-RPC request const request = JSON.parse(line); // Log incoming request this.logStdioMessage('RECV', request); // Validate JSON-RPC format if (request.jsonrpc !== '2.0') { await this.sendError(request.id, JSON_RPC_ERROR_CODES.INVALID_REQUEST, 'Invalid JSON-RPC version'); return; } if (!request.method) { await this.sendError(request.id, JSON_RPC_ERROR_CODES.INVALID_REQUEST, 'Missing method field'); return; } // Route request to handler await this.routeRequest(request); } catch (error) { // Parse error this.options.logger?.error('Failed to parse JSON-RPC request', error, { line }); await this.sendError(undefined, JSON_RPC_ERROR_CODES.PARSE_ERROR, 'Parse error', { originalLine: line }); } } /** Known notification methods that don't require a response */ static NOTIFICATION_METHODS = new Set([ 'initialized', 'notifications/initialized', 'notifications/cancelled', '$/cancelRequest', ]); /** Method to handler lookup table */ methodHandlers = { 'initialize': (req) => this.handleInitialize(req), 'tools/list': (req) => this.handleToolsList(req), 'tools/call': (req) => this.handleToolCall(req), 'resources/list': (req) => this.handleResourcesList(req), 'resources/read': (req) => this.handleResourceRead(req), 'prompts/list': (req) => this.handlePromptsList(req), 'prompts/get': (req) => this.handlePromptGet(req), 'ping': (req) => this.handlePing(req), }; /** * Routes a request to the appropriate handler. */ async routeRequest(request) { try { this.options.logger?.debug('Routing request', { method: request.method, id: request.id, }); // Check for known notification methods if (StdioTransport.NOTIFICATION_METHODS.has(request.method)) { this.options.logger?.debug('Received notification', { method: request.method }); return; } // Look up handler in dispatch table const handler = this.methodHandlers[request.method]; if (handler) { await handler(request); return; } // Unknown method await this.handleUnknownMethod(request); } catch (error) { this.options.logger?.error('Request routing failed', error, { method: request.method, }); // Only send error for requests (not notifications) if (request.id !== undefined) { await this.sendError(request.id, JSON_RPC_ERROR_CODES.INTERNAL_ERROR, 'Internal error', { error: String(error) }); } } } /** Handle unknown method - notification vs request */ async handleUnknownMethod(request) { if (request.id === undefined) { // Notification - do not respond (JSON-RPC 2.0 spec) this.options.logger?.debug('Unknown notification', { method: request.method }); } else { // Request - send error response this.options.logger?.warn('Unknown method', { method: request.method }); await this.sendError(request.id, JSON_RPC_ERROR_CODES.METHOD_NOT_FOUND, `Method not found: ${request.method}`); } } /** * Handles initialize request. */ async handleInitialize(request) { const params = request.params; const result = await this.server.handleInitialize(params); if (!isOk(result)) { await this.sendErrorFromBCError(request.id, result.error); return; } await this.sendSuccess(request.id, result.value); } /** * Handles tools/list request. */ async handleToolsList(request) { const result = await this.server.handleToolsList(); if (!isOk(result)) { await this.sendErrorFromBCError(request.id, result.error); return; } await this.sendSuccess(request.id, result.value); } /** * Handles tools/call request. */ async handleToolCall(request) { const params = request.params; const result = await this.server.handleToolCall(params); if (!isOk(result)) { await this.sendErrorFromBCError(request.id, result.error); return; } await this.sendSuccess(request.id, result.value); } /** * Handles resources/list request. */ async handleResourcesList(request) { const result = await this.server.handleResourcesList(); if (!isOk(result)) { await this.sendErrorFromBCError(request.id, result.error); return; } await this.sendSuccess(request.id, result.value); } /** * Handles resources/read request. */ async handleResourceRead(request) { const params = request.params; const result = await this.server.handleResourceRead(params); if (!isOk(result)) { await this.sendErrorFromBCError(request.id, result.error); return; } await this.sendSuccess(request.id, result.value); } /** * Handles prompts/list request. */ async handlePromptsList(request) { const result = await this.server.handlePromptsList(); if (!isOk(result)) { await this.sendErrorFromBCError(request.id, result.error); return; } await this.sendSuccess(request.id, result.value); } /** * Handles prompts/get request. */ async handlePromptGet(request) { const params = request.params; const result = await this.server.handlePromptGet(params); if (!isOk(result)) { await this.sendErrorFromBCError(request.id, result.error); return; } await this.sendSuccess(request.id, result.value); } /** * Handles ping request (keepalive). */ async handlePing(request) { await this.sendSuccess(request.id, { message: 'pong' }); } /** * Sends a success response. */ async sendSuccess(id, result) { const response = { jsonrpc: '2.0', id, result, }; await this.sendResponse(response); } /** * Sends an error response from BCError. * Uses centralized error mapping to convert BCError to appropriate JSON-RPC error codes. */ async sendErrorFromBCError(id, error) { const mcpError = toMCPError(error); await this.sendError(id, mcpError.code, mcpError.message, mcpError.data); } /** * Sends an error response. */ async sendError(id, code, message, data) { const error = { code, message, data, }; const response = { jsonrpc: '2.0', id, error, }; await this.sendResponse(response); } /** * Sends a JSON-RPC response to stdout. */ async sendResponse(response) { try { // Check if stdout is still writable if (this.stdoutClosed || !process.stdout.writable) { this.options.logger?.debug('Stdout not writable, skipping response', { stdoutClosed: this.stdoutClosed, writable: process.stdout.writable, }); return; } const json = JSON.stringify(response); if (this.options.enableDebugLogging) { this.options.logger?.debug('Sending response', { response }); } // Log outgoing response this.logStdioMessage('SEND', response); // Write to stdout with newline process.stdout.write(json + '\n'); } catch (error) { this.options.logger?.error('Failed to send response', error, { response }); // Mark stdout as closed if we get an EPIPE or similar write error if (error instanceof Error && 'code' in error && error.code === 'EPIPE') { this.stdoutClosed = true; } } } /** * Checks if transport is running. */ isRunning() { return this.running; } } //# sourceMappingURL=stdio-transport.js.map