import { Server, fromJsonSchema, isInputRequiredResult, INVALID_PARAMS, ProtocolError, type Resource, type ResourceTemplateType, type ServerContext, type Tool, type Transport, } from '@modelcontextprotocol/server'; import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; import winston from 'winston'; import { isSensitiveMaskEnabled, maskObjectDeep } from '../utils/SensitiveData.js'; import { isMcpToolResult } from '../utils/McpToolResult.js'; import { StringDecoder } from 'node:string_decoder'; import { sensitiveToolApprovalGuard } from '../security/SensitiveToolApproval.js'; /** * Plugin interface for extending MCP server functionality */ export interface MCPPlugin { name: string; initialize(server: MCPServer): Promise; shutdown?(): Promise; /** * Optional hook invoked when a new conversation starts. * Servers typically receive a tools listing at the beginning of a session, * so this hook is called from the ListTools handler. */ onNewConversation?(): Promise; } /** * Tool registry entry */ interface ToolEntry { tool: Tool; validator: ReturnType; outputValidator?: ReturnType; handler: (params: any, context?: ServerContext) => Promise; } /** * Prompt registry entry */ interface PromptEntry { name: string; description?: string; arguments?: Array<{ name: string; description?: string; required?: boolean }>; getMessages: ( args?: Record, ) => Promise>; } /** * Core MCP Server implementation for Kubernetes operations */ export interface MCPServerOptions { skipTransportErrorHandling?: boolean; skipGracefulShutdown?: boolean; toolListCacheHint?: { ttlMs: number; cacheScope: 'public' | 'private'; }; } export class MCPServer { private static readonly MAX_MALFORMED_STDIN_LOGS = 5; private server: Server; private transport?: Transport; private logger: winston.Logger; private hasLoggedStartupBegin = false; private hasLoggedStartupSuccess = false; private tools: Map = new Map(); private resources: Map = new Map(); private resourceTemplates: Map = new Map(); private plugins: Map = new Map(); private prompts: Map = new Map(); private isShuttingDown = false; private stdinChunkBuffer = ''; private malformedStdinCount = 0; private readonly stdinDecoder = new StringDecoder('utf8'); private readonly logMalformedPayloadPreview = process.env.MCP_LOG_MALFORMED_PAYLOAD_PREVIEW === 'true' || process.env.MCP_LOG_MALFORMED_PAYLOAD_PREVIEW === '1'; private options: MCPServerOptions; private eventListeners: Array<{ target: any; event: string; handler: (...args: any[]) => void; }> = []; constructor(options: MCPServerOptions = {}) { this.options = options; // Initialize MCP server (capabilities first so logging transport can use it) this.server = new Server( { name: 'kubeview-mcp', version: '', }, { capabilities: { tools: {}, resources: {}, prompts: {}, }, cacheHints: { 'tools/list': options.toolListCacheHint ?? { ttlMs: 300_000, cacheScope: 'public' }, }, requestState: { verify: sensitiveToolApprovalGuard.requestStateVerifier, }, }, ); // Initialize Winston logger (stderr only to avoid interfering with MCP stdout) this.logger = this.createLogger(); // Set up handlers this.setupHandlers(); // Set up graceful shutdown (skip in tests to avoid process listeners) if (!this.options.skipGracefulShutdown) { this.setupGracefulShutdown(); } this.logger.info('MCPServer initialized'); } /** * Add an event listener and track it for cleanup */ private addTrackedListener(target: any, event: string, handler: (...args: any[]) => void): void { target.on(event, handler); this.eventListeners.push({ target, event, handler }); } /** * Remove all tracked event listeners */ private removeAllListeners(): void { for (const { target, event, handler } of this.eventListeners) { try { target.removeListener(event, handler); } catch { // Ignore errors during cleanup } } this.eventListeners = []; } private isStdioTransport(transport: Transport | undefined): transport is StdioServerTransport { return transport instanceof StdioServerTransport; } /** * Set up stdio-specific error handling for the transport. */ private setupStdioTransportErrorHandling(): void { // Handle connection errors in the transport // StdioServerTransport uses the process stdin/stdout directly this.addTrackedListener(process.stdin, 'error', (error) => { this.logger.error('Transport stdin error:', error); // Don't throw - log and continue }); this.addTrackedListener(process.stdout, 'error', (error) => { this.logger.error('Transport stdout error:', error); // Don't throw - log and continue }); // Add additional error event listeners to catch more issues if (process.stdin.on && typeof process.stdin.on === 'function') { this.addTrackedListener(process.stdin, 'close', () => { this.logger.warn('Transport stdin closed unexpectedly'); this.gracefulRestart(); }); } if (process.stdout.on && typeof process.stdout.on === 'function') { this.addTrackedListener(process.stdout, 'close', () => { this.logger.warn('Transport stdout closed unexpectedly'); this.gracefulRestart(); }); } } /** * Attempt to gracefully restart the stdio server connection */ private async gracefulRestart(): Promise { if (this.isShuttingDown || !this.isStdioTransport(this.transport)) return; this.logger.info('Attempting to gracefully restart server connection...'); try { // Close existing connection await this.server.close(); // Small delay to allow resources to be released await new Promise((resolve) => setTimeout(resolve, 500)); // Create a new transport instance since the old one might be in an invalid state this.transport = new StdioServerTransport(); // Reconnect await this.server.connect(this.transport); this.logger.info('Server connection restarted successfully'); } catch (error) { this.logger.error('Failed to restart server connection', error); // If we get an error about the transport already being started, // try to create a completely new server instance if ( error instanceof Error && error.message.includes('StdioServerTransport already started') ) { this.logger.info('Attempting to create new server instance...'); try { // Re-initialize the server this.server = new Server( { name: 'kubeview-mcp', version: '', }, { capabilities: { tools: {}, resources: {}, prompts: {}, }, cacheHints: { 'tools/list': this.options.toolListCacheHint ?? { ttlMs: 300_000, cacheScope: 'public', }, }, requestState: { verify: sensitiveToolApprovalGuard.requestStateVerifier, }, }, ); // Recreate the transport this.transport = new StdioServerTransport(); // Re-register all handlers this.setupHandlers(); // Reconnect await this.server.connect(this.transport); this.logger.info('Server reconnected with new instance successfully'); } catch (nestedError) { this.logger.error('Failed to create new server instance', nestedError); } } } } /** * Set up request handlers for MCP protocol */ private setupHandlers(): void { // Handle tool listing this.server.setRequestHandler('tools/list', async () => { // Notify plugins that a new conversation has started. Most MCP clients // request tool listings at the start of a session. for (const plugin of this.plugins.values()) { if (typeof plugin.onNewConversation === 'function') { try { await plugin.onNewConversation(); } catch (err) { this.logger.warn('Plugin onNewConversation hook failed', { plugin: plugin.name, error: err, }); } } } const tools = Array.from(this.tools.values()).map((entry) => entry.tool); this.logger.debug(`Listing ${tools.length} tools`); return { tools }; }); // Handle tool execution this.server.setRequestHandler('tools/call', async (request, context) => { const toolEntry = this.tools.get(request.params.name); if (!toolEntry) { const error = `Tool not found: ${request.params.name}`; this.logger.error(error); throw new ProtocolError(INVALID_PARAMS, error); } const argsForLog = isSensitiveMaskEnabled() ? maskObjectDeep(request.params.arguments) : request.params.arguments; this.logger.info(`Executing tool: ${request.params.name}`, { arguments: argsForLog, }); try { const validation = await toolEntry.validator['~standard'].validate( request.params.arguments ?? {}, ); if (!('value' in validation)) { return this.executionErrorResult( `Invalid arguments for '${request.params.name}': ${validation.issues .map((issue) => issue.message) .join('; ')}`, ); } const result = await toolEntry.handler(validation.value, context); if (isInputRequiredResult(result)) { return result; } const outputPayload = isSensitiveMaskEnabled() ? maskObjectDeep(result) : result; const preserveJsonValue = (context.mcpReq.envelope as any)?.['io.modelcontextprotocol/protocolVersion'] === '2026-07-28'; const callResult = isMcpToolResult(result) ? this.withStructuredContent(result, preserveJsonValue) : { content: [ { type: 'text' as const, text: JSON.stringify(outputPayload, null, 2), }, ], structuredContent: this.asStructuredContent(outputPayload, preserveJsonValue), ...(this.isExecutionErrorPayload(outputPayload) ? { isError: true as const } : {}), }; return await this.validateToolOutput(request.params.name, toolEntry, callResult); } catch (error) { this.logger.error(`Tool execution failed: ${request.params.name}`, error); return this.executionErrorResult(error instanceof Error ? error.message : String(error)); } }); // Handle resource listing this.server.setRequestHandler('resources/list', async () => { const resources = Array.from(this.resources.values()); this.logger.debug(`Listing ${resources.length} resources`); return { resources }; }); // Handle resource reading this.server.setRequestHandler('resources/read', async (request) => { const resource = this.resources.get(request.params.uri); if (!resource) { const error = `Resource not found: ${request.params.uri}`; this.logger.error(error); throw new Error(error); } this.logger.info(`Reading resource: ${request.params.uri}`); // If the resource has text content directly attached (custom extension for this server) // or if we store content in a separate map. // For now, let's assume we might extend the Resource type or store it separately. // But wait, the Resource interface from SDK doesn't have 'text'. // We should probably store the content in a separate map or extend the type locally. // Let's check how we register it. // Since we are defining the server, we can cast it or look it up. // Let's assume we store content in a parallel map or cast to any. const content = (resource as any).text || `Resource content for ${request.params.uri}`; return { contents: [ { type: 'text', text: content, uri: request.params.uri, mimeType: resource.mimeType, }, ], }; }); // Handle resource template listing this.server.setRequestHandler('resources/templates/list', async () => { const templates = Array.from(this.resourceTemplates.values()); this.logger.debug(`Listing ${templates.length} resource templates`); return { resourceTemplates: templates }; }); // Handle prompt listing this.server.setRequestHandler('prompts/list', async () => { const prompts = Array.from(this.prompts.values()).map((entry) => ({ name: entry.name, description: entry.description, arguments: entry.arguments, })); this.logger.debug(`Listing ${prompts.length} prompts`); return { prompts }; }); // Handle prompt getting this.server.setRequestHandler('prompts/get', async (request) => { const promptEntry = this.prompts.get(request.params.name); if (!promptEntry) { const error = `Prompt not found: ${request.params.name}`; this.logger.error(error); throw new Error(error); } this.logger.info(`Getting prompt: ${request.params.name}`); const messages = await promptEntry.getMessages(request.params.arguments); return { description: promptEntry.description, messages, }; }); } /** * Register a tool with the MCP server */ public registerTool( tool: Tool, handler: (params: any, context?: ServerContext) => Promise, ): void { if (this.tools.has(tool.name)) { this.logger.warn(`Tool already registered: ${tool.name}, overwriting`); } const normalizedTool = this.normalizeToolDefinition(tool); this.tools.set(tool.name, { tool: normalizedTool, validator: fromJsonSchema(normalizedTool.inputSchema as any), outputValidator: normalizedTool.outputSchema ? fromJsonSchema(normalizedTool.outputSchema as any) : undefined, handler, }); this.logger.info(`Registered tool: ${tool.name}`); } /** * Get all registered tools */ public getTools(): Tool[] { return Array.from(this.tools.values()).map((t) => t.tool); } /** * Execute a tool directly (for internal use, e.g., from run_code sandbox) */ public async executeTool(toolName: string, params: unknown): Promise { const toolEntry = this.tools.get(toolName); if (!toolEntry) { throw new Error(`Tool not found: ${toolName}`); } const validation = await toolEntry.validator['~standard'].validate(params ?? {}); if (!('value' in validation)) { throw new Error( `Invalid arguments for '${toolName}': ${validation.issues .map((issue) => issue.message) .join('; ')}`, ); } return toolEntry.handler(validation.value); } /** * Register a resource with the MCP server */ public registerResource(resource: Resource): void { if (this.resources.has(resource.uri)) { this.logger.warn(`Resource already registered: ${resource.uri}, overwriting`); } this.resources.set(resource.uri, resource); this.logger.info(`Registered resource: ${resource.uri}`); } /** * Register a prompt with the MCP server */ public registerPrompt(prompt: PromptEntry): void { if (this.prompts.has(prompt.name)) { this.logger.warn(`Prompt already registered: ${prompt.name}, overwriting`); } this.prompts.set(prompt.name, prompt); this.logger.info(`Registered prompt: ${prompt.name}`); } /** * Load and initialize a plugin */ public async loadPlugin(plugin: MCPPlugin): Promise { if (this.plugins.has(plugin.name)) { throw new Error(`Plugin already loaded: ${plugin.name}`); } this.logger.info(`Loading plugin: ${plugin.name}`); try { await plugin.initialize(this); this.plugins.set(plugin.name, plugin); this.logger.info(`Plugin loaded successfully: ${plugin.name}`); } catch (error) { this.logger.error(`Failed to load plugin: ${plugin.name}`, error); throw error; } } /** * Get a loaded plugin by name */ public getPlugin(name: string): MCPPlugin | undefined { return this.plugins.get(name); } /** * Start the MCP server */ public async start(): Promise { await this.startWithTransport(new StdioServerTransport()); } /** * Start the MCP server with an explicit transport. */ public async startWithTransport(transport: Transport): Promise { this.logStartupBegin(); this.transport = transport; try { if (this.options.skipTransportErrorHandling) { // Simple connection for tests await this.server.connect(transport); } else if (this.isStdioTransport(transport)) { this.setupStdioTransportErrorHandling(); await this.connectStdioWithErrorHandling(transport); } else { await this.server.connect(transport); } this.logStartupSuccess(); } catch (error) { this.logger.error('Failed to start MCP server', error); throw error; } } /** * Connect to stdio with improved error handling */ private async connectStdioWithErrorHandling(transport: StdioServerTransport): Promise { // Validate incoming JSON-RPC frames without disrupting SDK listeners. const validateChunk = (chunk: Buffer | string): void => { try { const asString = Buffer.isBuffer(chunk) ? this.stdinDecoder.write(chunk) : chunk; if (asString) this.inspectIncomingTransportData(asString); } catch (error) { this.logger.warn('Failed to inspect incoming MCP transport data', { error: error instanceof Error ? error.message : String(error), }); } }; if (typeof process.stdin.prependListener === 'function') { process.stdin.prependListener('data', validateChunk); this.eventListeners.push({ target: process.stdin, event: 'data', handler: validateChunk, }); } else { this.addTrackedListener(process.stdin, 'data', validateChunk); } // Connect to the transport await this.server.connect(transport); } /** * Inspect newline-delimited JSON-RPC messages and emit user-friendly diagnostics. */ private inspectIncomingTransportData(chunk: string): void { this.stdinChunkBuffer += chunk; const lines = this.stdinChunkBuffer.split(/\r?\n/); this.stdinChunkBuffer = lines.pop() ?? ''; for (const line of lines) { this.inspectTransportLine(line); } } private inspectTransportLine(line: string): void { const trimmed = line.trim(); if (!trimmed) return; try { JSON.parse(trimmed); } catch (error) { const message = error instanceof Error ? error.message : String(error); const payloadPreview = this.logMalformedPayloadPreview && !isSensitiveMaskEnabled() ? `${trimmed.slice(0, 160)}${trimmed.length > 160 ? '...' : ''}` : undefined; if (this.malformedStdinCount < MCPServer.MAX_MALFORMED_STDIN_LOGS) { this.logger.warn('MCP transport connection issue: malformed JSON-RPC payload', { reason: message, payloadPreview, payloadLength: trimmed.length, hint: 'Check that the client sends one valid JSON object per line to stdin.', }); } else if (this.malformedStdinCount === MCPServer.MAX_MALFORMED_STDIN_LOGS) { this.logger.warn('Additional malformed MCP transport payloads are being suppressed'); } this.malformedStdinCount += 1; } } /** * Stop the MCP server */ public async stop(): Promise { await this.shutdown(true); } /** * Release application resources after a per-request transport has already closed. */ public async cleanupAfterTransportClose(): Promise { await this.shutdown(false); } private async shutdown(closeServer: boolean): Promise { if (this.isShuttingDown) { return; } this.isShuttingDown = true; this.logger.info('Stopping MCP server...'); // Remove all tracked event listeners this.removeAllListeners(); // Shutdown plugins for (const [name, plugin] of this.plugins) { if (plugin.shutdown) { try { await plugin.shutdown(); this.logger.info(`Plugin shutdown complete: ${name}`); } catch (error) { this.logger.error(`Plugin shutdown failed: ${name}`, error); } } } if (closeServer) { await this.server.close(); } this.logger.info('MCP server stopped'); } /** * Set up graceful shutdown handling */ private setupGracefulShutdown(): void { const shutdown = async (signal: string) => { this.logger.info(`Received ${signal}, initiating graceful shutdown...`); await this.stop(); process.exit(0); }; this.addTrackedListener(process, 'SIGINT', () => shutdown('SIGINT')); this.addTrackedListener(process, 'SIGTERM', () => shutdown('SIGTERM')); this.addTrackedListener(process, 'uncaughtException', (error) => { this.logger.error('Uncaught exception:', error); shutdown('uncaughtException'); }); this.addTrackedListener(process, 'unhandledRejection', (reason) => { this.logger.error('Unhandled rejection:', reason); shutdown('unhandledRejection'); }); } /** * Get the logger instance */ public getLogger(): winston.Logger { return this.logger; } /** * Get the underlying MCP server instance */ public getServer(): Server { return this.server; } /** * Clean up resources and event listeners (useful for tests) */ public cleanup(): void { this.removeAllListeners(); } /** * Emit the standard startup-begin log once, even if startup is split across phases. */ public logStartupBegin(): void { if (this.hasLoggedStartupBegin) { return; } this.hasLoggedStartupBegin = true; this.logger.info('Starting MCP server...'); } /** * Emit the standard startup-success log once, even if startup is split across phases. */ public logStartupSuccess(): void { if (this.hasLoggedStartupSuccess) { return; } this.hasLoggedStartupSuccess = true; this.logger.info('MCP server started successfully'); } /** Build a stderr-only logger with optional file output. */ private createLogger(): winston.Logger { // Some environments shim winston; only call errors() when it's actually a function. const rawErrors = (winston.format as any).errors; const errorFormatter = typeof rawErrors === 'function' ? rawErrors({ stack: true }) : undefined; const splatFormatter = (winston.format as any).splat ? (winston.format as any).splat() : winston.format.combine(); const baseFormat = winston.format.combine( winston.format.timestamp(), errorFormatter ?? winston.format.combine(), splatFormatter, ); const printfFactory = (winston.format as any).printf; const colorizeFactory = (winston.format as any).colorize; const streamCtor = (winston.transports as any).Stream; const consoleCtor = (winston.transports as any).Console; const stderrTransport = streamCtor ? new streamCtor({ stream: process.stderr, handleExceptions: false, format: winston.format.combine( typeof colorizeFactory === 'function' ? colorizeFactory() : winston.format.combine(), typeof printfFactory === 'function' ? printfFactory( ({ level, message, timestamp, stack, ...meta }: Record) => { const rest = Object.keys(meta).length ? ` ${JSON.stringify(meta)}` : ''; const printable = stack || message; return `${timestamp} [${level}] ${printable}${rest}`; }, ) : winston.format.combine(), ), }) : new consoleCtor({ stderrLevels: ['error', 'warn', 'info', 'verbose', 'debug', 'silly'], consoleWarnLevels: ['warn'], }); const transports: winston.transport[] = [stderrTransport]; // Optional file logging controlled by env const isFileLogEnabled = process.env.MCP_LOG_ENABLE === 'true' || process.env.MCP_LOG_ENABLE === '1'; if (isFileLogEnabled) { const logFilePath = process.env.MCP_LOG_FILE || 'kubeview-mcp.log'; transports.push( new winston.transports.File({ filename: logFilePath, format: winston.format.combine(winston.format.timestamp(), winston.format.json()), }), ); } const logger = winston.createLogger({ level: process.env.MCP_LOG_LEVEL || 'info', format: baseFormat, transports, exitOnError: false, }); return logger; } private normalizeToolDefinition(tool: Tool): Tool { const stripOptionalKeyword = (value: unknown): unknown => { if (Array.isArray(value)) return value.map(stripOptionalKeyword); if (!value || typeof value !== 'object') return value; return Object.fromEntries( Object.entries(value as Record) .filter(([key]) => key !== 'optional') .map(([key, child]) => [key, stripOptionalKeyword(child)]), ); }; return { ...tool, title: tool.title ?? tool.name.replaceAll('_', ' '), inputSchema: { ...(stripOptionalKeyword(tool.inputSchema) as Record), $schema: 'https://json-schema.org/draft/2020-12/schema', } as unknown as Tool['inputSchema'], outputSchema: tool.outputSchema ? ({ $schema: 'https://json-schema.org/draft/2020-12/schema', ...(stripOptionalKeyword(tool.outputSchema) as Record), } as Tool['outputSchema']) : undefined, annotations: { title: tool.title ?? tool.name.replaceAll('_', ' '), readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, ...tool.annotations, }, }; } private asStructuredContent(value: unknown, preserveJsonValue: boolean): any { if (preserveJsonValue) return value === undefined ? null : value; return value && typeof value === 'object' && !Array.isArray(value) ? value : { result: value }; } private withStructuredContent(result: any, preserveJsonValue: boolean): any { if (result.structuredContent !== undefined) { const structuredContent = this.asStructuredContent( result.structuredContent, preserveJsonValue, ); return this.withJsonTextFallback(result, structuredContent); } const text = result.content?.find((item: any) => item.type === 'text')?.text; if (typeof text !== 'string') return result; try { const structuredContent = this.asStructuredContent(JSON.parse(text), preserveJsonValue); return this.withJsonTextFallback(result, structuredContent); } catch { const structuredContent = this.asStructuredContent(text, preserveJsonValue); return this.withJsonTextFallback(result, structuredContent); } } private withJsonTextFallback(result: any, structuredContent: unknown): any { const fallback = JSON.stringify(structuredContent); const hasFallback = result.content?.some((item: any) => { if (item.type !== 'text' || typeof item.text !== 'string') return false; if (item.text === fallback) return true; try { return JSON.stringify(JSON.parse(item.text)) === fallback; } catch { return false; } }); return { ...result, content: hasFallback ? result.content : [...(result.content ?? []), { type: 'text', text: fallback }], structuredContent, }; } private isExecutionErrorPayload(value: unknown): boolean { return Boolean( value && typeof value === 'object' && 'isError' in value && (value as { isError?: unknown }).isError === true, ); } private async validateToolOutput(toolName: string, entry: ToolEntry, result: any): Promise { if (!entry.outputValidator || result.isError || result.structuredContent === undefined) { return result; } const validation = await entry.outputValidator['~standard'].validate(result.structuredContent); if ('issues' in validation && validation.issues?.length) { return this.executionErrorResult( `Invalid output from '${toolName}': ${validation.issues .map((issue) => issue.message) .join('; ')}`, ); } return result; } private executionErrorResult(message: string): { isError: true; content: Array<{ type: 'text'; text: string }>; structuredContent: Record; } { return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: message }) }], structuredContent: { error: message }, }; } }