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

460 lines 17.6 kB
/** * MCP Server Implementation * * Implements the Model Context Protocol server for Business Central integration. * Handles JSON-RPC 2.0 communication over stdio with Claude Desktop. * * Features: * - Tool registration and execution * - Resource registration and serving * - Initialize/shutdown lifecycle * - Error handling and logging * - Protocol compliance */ import { ok, err, isOk } from '../core/result.js'; import { InternalError } from '../core/errors.js'; import { PageContextCache } from './page-context-cache.js'; import { debugLogger } from './debug-logger.js'; import { config } from '../core/config.js'; // ============================================================================ // MCP Server Implementation // ============================================================================ /** * MCP Server for Business Central. * * Implements the Model Context Protocol server specification. * Communicates with Claude Desktop via JSON-RPC 2.0 over stdio. */ export class MCPServer { logger; tools = new Map(); resources = new Map(); initialized = false; running = false; clientInfo; constructor(logger) { this.logger = logger; } /** * Initializes the server. * Must be called before start(). */ async initialize() { try { if (this.initialized) { return err(new InternalError('Server already initialized', { state: 'initialized', code: 'ALREADY_INITIALIZED' })); } this.logger?.info('Initializing MCP server', { tools: this.tools.size, resources: this.resources.size, }); this.initialized = true; this.logger?.info('MCP server initialized successfully'); return ok(undefined); } catch (error) { return err(new InternalError('Failed to initialize server', { code: 'INITIALIZATION_FAILED', error: String(error) })); } } /** * Registers a tool with the server. * Tools can be registered before or after initialization. */ registerTool(tool) { if (this.tools.has(tool.name)) { this.logger?.warn('Tool already registered, overwriting', { toolName: tool.name, }); } this.tools.set(tool.name, tool); this.logger?.debug('Tool registered', { toolName: tool.name, description: tool.description, }); } /** * Registers a resource with the server. * Resources can be registered before or after initialization. */ registerResource(resource) { if (this.resources.has(resource.uri)) { this.logger?.warn('Resource already registered, overwriting', { resourceUri: resource.uri, }); } this.resources.set(resource.uri, resource); this.logger?.debug('Resource registered', { resourceUri: resource.uri, resourceName: resource.name, }); } /** * Starts the server. * Begins listening for JSON-RPC requests on stdin. */ async start() { try { if (!this.initialized) { return err(new InternalError('Server not initialized', { code: 'NOT_INITIALIZED', state: 'not_initialized' })); } if (this.running) { return err(new InternalError('Server already running', { code: 'ALREADY_RUNNING', state: 'running' })); } this.logger?.info('Starting MCP server', { tools: this.tools.size, resources: this.resources.size, }); // Initialize persistent pageContext cache try { const cache = PageContextCache.getInstance(); await cache.initialize(); this.logger?.info('PageContext cache initialized'); } catch (error) { this.logger?.error(`Failed to initialize PageContext cache: ${error}`); // Non-fatal: continue without persistent cache } // Initialize debug logger if (config.debug.enabled) { try { await debugLogger.initialize(); this.logger?.info('Debug logging enabled', { channels: Array.from(config.debug.channels), logDir: config.debug.logDir, }); } catch (error) { this.logger?.error(`Failed to initialize debug logging: ${error}`); // Non-fatal: continue without debug logging } } this.running = true; // Note: Actual stdio processing handled by stdio-transport.ts // This method just marks the server as running this.logger?.info('MCP server started successfully'); return ok(undefined); } catch (error) { return err(new InternalError('Failed to start server', { code: 'START_FAILED', error: String(error) })); } } /** * Stops the server gracefully. * Closes all connections and cleans up resources. */ async stop() { try { if (!this.running) { return err(new InternalError('Server not running', { code: 'NOT_RUNNING', state: 'stopped' })); } this.logger?.info('Stopping MCP server'); // Shutdown debug logger if (config.debug.enabled) { try { await debugLogger.shutdown(); this.logger?.info('Debug logging shut down'); } catch (error) { this.logger?.error(`Failed to shutdown debug logging: ${error}`); } } this.running = false; this.logger?.info('MCP server stopped successfully'); return ok(undefined); } catch (error) { return err(new InternalError('Failed to stop server', { code: 'STOP_FAILED', error: String(error) })); } } /** * Gets all registered tools. */ getTools() { return Array.from(this.tools.values()); } /** * Gets all registered resources. */ getResources() { return Array.from(this.resources.values()); } // ============================================================================ // Protocol Request Handlers // ============================================================================ /** * Handles MCP initialize request. */ async handleInitialize(params) { try { this.logger?.info('Handling initialize request', { clientName: params.clientInfo.name, clientVersion: params.clientInfo.version, protocolVersion: params.protocolVersion, }); // Store client info this.clientInfo = params.clientInfo; const result = { protocolVersion: '2025-06-18', // MCP protocol version upgraded to support prompts capabilities: { tools: this.tools.size > 0 ? {} : undefined, resources: this.resources.size > 0 ? {} : undefined, prompts: {}, // Prompts capability enabled }, serverInfo: { name: 'bc-webclient-mcp', version: '1.0.0', }, }; this.logger?.debug('Initialize response', { protocolVersion: result.protocolVersion, serverName: result.serverInfo.name, serverVersion: result.serverInfo.version, }); return ok(result); } catch (error) { return err(new InternalError('Failed to handle initialize request', { code: 'INITIALIZE_REQUEST_FAILED', error: String(error) })); } } /** * Handles tools/list request. * Includes consent metadata via annotations for MCP 2025 compliance. */ async handleToolsList() { try { this.logger?.debug('Handling tools/list request'); const toolsList = Array.from(this.tools.values()).map(tool => ({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema, // Include consent metadata in MCP response // MCP spec uses "annotations" for metadata extensions annotations: { requiresConsent: tool.requiresConsent ?? false, consentPrompt: tool.consentPrompt, sensitivityLevel: tool.sensitivityLevel ?? 'medium', }, })); this.logger?.debug('Returning tools list', { count: toolsList.length, withConsent: toolsList.filter(t => t.annotations?.requiresConsent).length, }); return ok({ tools: toolsList }); } catch (error) { return err(new InternalError('Failed to handle tools/list request', { code: 'TOOLS_LIST_FAILED', error: String(error) })); } } /** * Handles tools/call request. */ async handleToolCall(params) { try { this.logger?.info('Handling tools/call request', { toolName: params.name, }); // Get tool const tool = this.tools.get(params.name); if (!tool) { return err(new InternalError(`Tool not found: ${params.name}`, { code: 'TOOL_NOT_FOUND', toolName: params.name })); } // Execute tool const result = await tool.execute(params.arguments); if (!isOk(result)) { this.logger?.error('Tool execution failed', result.error, { toolName: params.name, errorCode: result.error.code, }); return result; } this.logger?.debug('Tool executed successfully', { toolName: params.name, }); // Wrap result in MCP content format // According to MCP specification (2025-06-18): // - structuredContent: provides raw JSON for proper client rendering // - content (text): provides stringified JSON for backwards compatibility const mcpResponse = { content: [ { type: 'text', text: JSON.stringify(result.value, null, 2), }, ], structuredContent: result.value, }; return ok(mcpResponse); } catch (error) { let errorStr; let stack; if (error instanceof Error) { errorStr = error.message; stack = error.stack; } else if (error && typeof error === 'object') { // Check if it's a Result error object const obj = error; if ('error' in obj && obj.error && typeof obj.error === 'object') { const bcError = obj.error; errorStr = `Result.error: ${bcError.message || 'unknown'} (code: ${bcError.code || 'unknown'})`; } else if ('message' in obj) { errorStr = String(obj.message); } else { // Try to extract some useful info const keys = Object.keys(obj).slice(0, 5); errorStr = `Object with keys: ${keys.join(', ')}`; } } else { try { errorStr = JSON.stringify(error); } catch { errorStr = `Non-serializable error: ${Object.prototype.toString.call(error)}`; } } // Use console.error directly to ensure we see the error - logger might be swallowing it console.error('[MCP-SERVER] UNCAUGHT EXCEPTION in tool call:', params.name, 'Error:', errorStr, 'Stack:', stack); this.logger?.error('Tool call threw uncaught exception', { toolName: params.name, error: errorStr, stack }); return err(new InternalError('Failed to handle tools/call request', { code: 'TOOL_CALL_FAILED', toolName: params.name, error: errorStr })); } } /** * Handles resources/list request. */ async handleResourcesList() { try { this.logger?.debug('Handling resources/list request'); const resourcesList = Array.from(this.resources.values()).map(resource => ({ uri: resource.uri, name: resource.name, description: resource.description, mimeType: resource.mimeType, })); this.logger?.debug('Returning resources list', { count: resourcesList.length }); return ok({ resources: resourcesList }); } catch (error) { return err(new InternalError('Failed to handle resources/list request', { code: 'RESOURCES_LIST_FAILED', error: String(error) })); } } /** * Handles resources/read request. */ async handleResourceRead(params) { try { this.logger?.info('Handling resources/read request', { uri: params.uri, }); // Get resource const resource = this.resources.get(params.uri); if (!resource) { return err(new InternalError(`Resource not found: ${params.uri}`, { code: 'RESOURCE_NOT_FOUND', uri: params.uri })); } // Read resource const contentResult = await resource.read(); if (!isOk(contentResult)) { this.logger?.error('Resource read failed', contentResult.error, { uri: params.uri, errorCode: contentResult.error.code, }); return contentResult; } this.logger?.debug('Resource read successfully', { uri: params.uri, contentLength: contentResult.value.length, }); // Return MCP-compliant resource content structure return ok({ contents: [ { uri: params.uri, mimeType: resource.mimeType, text: contentResult.value, }, ], }); } catch (error) { return err(new InternalError('Failed to handle resources/read request', { code: 'RESOURCE_READ_FAILED', uri: params.uri, error: String(error) })); } } /** * Handles prompts/list request. */ async handlePromptsList() { try { this.logger?.debug('Handling prompts/list request'); // Import prompts dynamically to avoid circular dependencies const { listPrompts } = await import('../prompts/index.js'); const prompts = listPrompts().map((p) => ({ name: p.name, description: p.description, arguments: p.arguments, })); this.logger?.debug('Returning prompts list', { count: prompts.length }); return ok({ prompts }); } catch (error) { return err(new InternalError('Failed to handle prompts/list request', { code: 'PROMPTS_LIST_FAILED', error: String(error), })); } } /** * Handles prompts/get request. */ async handlePromptGet(params) { try { this.logger?.info('Handling prompts/get request', { promptName: params.name, }); // Import prompts dynamically to avoid circular dependencies const { getPromptByName, buildPromptResult } = await import('../prompts/index.js'); const template = getPromptByName(params.name); if (!template) { return err(new InternalError(`Prompt not found: ${params.name}`, { code: 'PROMPT_NOT_FOUND', promptName: params.name, })); } const args = params.arguments ?? {}; // Optional: enforce required args; for now we rely on the template content being clear. const result = buildPromptResult(template, args); this.logger?.debug('Prompt rendered', { promptName: params.name, }); return ok(result); } catch (error) { return err(new InternalError('Failed to handle prompts/get request', { code: 'PROMPT_GET_FAILED', promptName: params.name, error: String(error), })); } } /** * Checks if server is initialized. */ isInitialized() { return this.initialized; } /** * Checks if server is running. */ isRunning() { return this.running; } /** * Gets client information (available after initialize). */ getClientInfo() { return this.clientInfo; } } //# sourceMappingURL=mcp-server.js.map