/** * Mandu Kitchen DevTools - MCP Connector * @version 1.1.0 * * Model Context Protocol (MCP) 연동 * - AI 에이전트와의 통신 * - 컨텍스트 전달 * - 수정 제안 수신 */ import type { AIContextPayload, NormalizedError } from '../types'; import type { AIContextBuilder} from './context-builder'; import { getContextBuilder } from './context-builder'; // ============================================================================ // Types // ============================================================================ export interface MCPConnectorOptions { /** MCP 서버 URL */ serverUrl?: string; /** 연결 타임아웃 (ms) */ connectionTimeout?: number; /** 요청 타임아웃 (ms) */ requestTimeout?: number; /** 자동 재연결 */ autoReconnect?: boolean; /** 재연결 간격 (ms) */ reconnectInterval?: number; /** 최대 재연결 시도 횟수 */ maxReconnectAttempts?: number; } export interface MCPMessage { id: string; type: 'request' | 'response' | 'notification'; method?: string; params?: unknown; result?: unknown; error?: { code: number; message: string; data?: unknown; }; } export interface AnalysisRequest { context: AIContextPayload; options?: { includeFixSuggestion?: boolean; includeExplanation?: boolean; language?: string; }; } export interface AnalysisResponse { success: boolean; analysis?: { rootCause: string; explanation: string; severity: 'critical' | 'high' | 'medium' | 'low'; category: string; }; fixSuggestion?: { description: string; code?: string; file?: string; lineRange?: [number, number]; confidence: number; }; relatedDocs?: Array<{ title: string; url: string; }>; error?: string; } export type MCPConnectionStatus = | 'disconnected' | 'connecting' | 'connected' | 'error'; // ============================================================================ // Constants // ============================================================================ /** * #181: 이전에는 `ws://{host}:{port}/mcp` 형태로 자동 유추했으나, dev 서버는 * 해당 엔드포인트를 제공하지 않아 브라우저가 reconnect 루프에 빠져 멈추는 문제가 있었음. * 이제는 명시적인 `serverUrl`을 받지 못하면 connect() 자체가 실패하도록 한다. * 호출자가 의도적으로 MCP 엔드포인트 URL을 넘겨야만 WebSocket을 시도한다. */ const DEFAULT_OPTIONS: Required = { serverUrl: '', connectionTimeout: 5000, requestTimeout: 30000, // #175: MCP 서버가 없을 수 있으므로 기본 비활성화 autoReconnect: false, reconnectInterval: 1000, maxReconnectAttempts: 3, }; // ============================================================================ // MCP Connector // ============================================================================ export class MCPConnector { private options: Required; private ws: WebSocket | null = null; private status: MCPConnectionStatus = 'disconnected'; private requestIdCounter = 0; private pendingRequests = new Map< string, { resolve: (result: unknown) => void; reject: (error: Error) => void; timeout: ReturnType; } >(); private reconnectAttempts = 0; private reconnectTimer: ReturnType | null = null; private statusListeners = new Set<(status: MCPConnectionStatus) => void>(); private contextBuilder: AIContextBuilder; constructor(options: MCPConnectorOptions = {}) { this.options = { ...DEFAULT_OPTIONS, ...options }; this.contextBuilder = getContextBuilder(); } // -------------------------------------------------------------------------- // Connection Management // -------------------------------------------------------------------------- /** * MCP 서버에 연결 */ connect(): Promise { return new Promise((resolve, reject) => { if (this.status === 'connected' && this.ws) { resolve(); return; } // #181: 명시적 serverUrl이 없으면 즉시 실패. 자동 URL 유추는 // 존재하지 않는 /mcp 엔드포인트로 향해 브라우저를 멈추게 했음. const url = this.options.serverUrl; if (!url) { const err = new Error( '[Mandu Kitchen] MCPConnector.connect() requires an explicit serverUrl. ' + 'No MCP WebSocket endpoint is started by the dev server.', ); this.handleConnectionError(err); reject(err); return; } this.setStatus('connecting'); const timeout = setTimeout(() => { reject(new Error('Connection timeout')); this.handleConnectionError(new Error('Connection timeout')); }, this.options.connectionTimeout); try { this.ws = new WebSocket(url); this.ws.addEventListener('open', () => { clearTimeout(timeout); this.setStatus('connected'); this.reconnectAttempts = 0; resolve(); }); this.ws.addEventListener('close', () => { this.handleDisconnect(); }); this.ws.addEventListener('error', () => { clearTimeout(timeout); this.handleConnectionError(new Error('WebSocket error')); reject(new Error('WebSocket connection failed')); }); this.ws.addEventListener('message', (event) => { this.handleMessage(event.data); }); } catch (error) { clearTimeout(timeout); this.handleConnectionError( error instanceof Error ? error : new Error(String(error)) ); reject(error); } }); } /** * 연결 종료 */ disconnect(): void { if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } if (this.ws) { this.ws.close(); this.ws = null; } // 대기 중인 요청 모두 reject for (const [, pending] of this.pendingRequests) { clearTimeout(pending.timeout); pending.reject(new Error('Connection closed')); } this.pendingRequests.clear(); this.setStatus('disconnected'); } /** * 연결 상태 조회 */ getStatus(): MCPConnectionStatus { return this.status; } /** * 상태 변경 리스너 등록 */ onStatusChange( listener: (status: MCPConnectionStatus) => void ): () => void { this.statusListeners.add(listener); return () => this.statusListeners.delete(listener); } // -------------------------------------------------------------------------- // MCP Methods // -------------------------------------------------------------------------- /** * 에러 분석 요청 */ async analyzeError( error: NormalizedError, options?: AnalysisRequest['options'] ): Promise { // 컨텍스트 빌드 const context = await this.contextBuilder.buildContext(error); return this.sendRequest('mandu/analyze', { context, options: { includeFixSuggestion: true, includeExplanation: true, language: 'ko', ...options, }, }); } /** * 수정 제안 요청 */ async getSuggestion( error: NormalizedError, codeContext: string ): Promise { const result = await this.sendRequest<{ suggestion: AnalysisResponse['fixSuggestion']; }>('mandu/suggest', { error: { message: error.message, type: error.type, source: error.source, line: error.line, }, codeContext, }); return result.suggestion; } /** * 관련 문서 검색 */ async searchDocs( query: string ): Promise { const result = await this.sendRequest<{ docs: AnalysisResponse['relatedDocs']; }>('mandu/docs', { query }); return result.docs; } /** * 클립보드용 컨텍스트 생성 * (AI 에이전트에 붙여넣기 위한 포맷) */ async formatForClipboard(error: NormalizedError): Promise { const context = await this.contextBuilder.buildContext(error); const parts: string[] = [ '## Error Report', '', `**Type**: ${error.type}`, `**Severity**: ${error.severity}`, `**Message**: ${error.message}`, '', ]; if (error.source) { parts.push(`**Location**: ${error.source}:${error.line ?? '?'}:${error.column ?? '?'}`); parts.push(''); } if (error.stack) { parts.push('### Stack Trace'); parts.push('```'); parts.push(error.stack); parts.push('```'); parts.push(''); } if (context.codeContext?.snippet) { parts.push('### Source Code'); parts.push('```typescript'); parts.push(context.codeContext.snippet.content); parts.push('```'); parts.push(''); } if (context.island) { parts.push('### Island Info'); parts.push(`- Name: ${context.island.name}`); parts.push(`- Status: ${context.island.status}`); parts.push(`- Strategy: ${context.island.strategy}`); parts.push(''); } if (context.recentErrors && context.recentErrors.length > 0) { parts.push('### Recent Related Errors'); for (const e of context.recentErrors) { const timeAgo = Math.round((Date.now() - e.timestamp) / 1000); parts.push(`- ${e.message} (${timeAgo}s ago)`); } parts.push(''); } parts.push('---'); parts.push(`*Generated by Mandu Kitchen DevTools v${context.devtools.version}*`); return parts.join('\n'); } // -------------------------------------------------------------------------- // Internal // -------------------------------------------------------------------------- private setStatus(status: MCPConnectionStatus): void { if (this.status !== status) { this.status = status; for (const listener of this.statusListeners) { try { listener(status); } catch { // 리스너 에러 무시 } } } } private handleMessage(data: string): void { try { const message: MCPMessage = JSON.parse(data); if (message.type === 'response' && message.id) { const pending = this.pendingRequests.get(message.id); if (pending) { clearTimeout(pending.timeout); this.pendingRequests.delete(message.id); if (message.error) { pending.reject(new Error(message.error.message)); } else { pending.resolve(message.result); } } } } catch (error) { console.warn('[Mandu Kitchen] Failed to parse MCP message:', error); } } private handleDisconnect(): void { this.ws = null; // #175: handleConnectionError에서 이미 reconnect 예약한 경우 중복 방지 if (this.options.autoReconnect && this.status !== 'disconnected' && !this.reconnectTimer) { this.setStatus('error'); this.scheduleReconnect(); } else if (!this.reconnectTimer) { this.setStatus('disconnected'); } } private handleConnectionError(error: Error): void { console.warn('[Mandu Kitchen] MCP connection error:', error.message); this.setStatus('error'); if (this.options.autoReconnect && !this.reconnectTimer) { this.scheduleReconnect(); } } private scheduleReconnect(): void { // #175: 기존 타이머 취소 (중복 방지) if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } if (this.reconnectAttempts >= this.options.maxReconnectAttempts) { console.warn('[Mandu Kitchen] Max reconnection attempts reached'); this.setStatus('disconnected'); return; } this.reconnectAttempts++; // #175: exponential backoff (1s → 2s → 4s → ... → max 30s) const delay = Math.min( this.options.reconnectInterval * Math.pow(2, this.reconnectAttempts - 1), 30_000, ); console.log( `[Mandu Kitchen] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.options.maxReconnectAttempts})` ); this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; this.connect().catch(() => { // 에러는 handleConnectionError에서 처리됨 }); }, delay); } private async sendRequest(method: string, params: unknown): Promise { if (!this.ws || this.status !== 'connected') { throw new Error('Not connected to MCP server'); } const id = `req-${++this.requestIdCounter}`; return new Promise((resolve, reject) => { const timeout = setTimeout(() => { this.pendingRequests.delete(id); reject(new Error('Request timeout')); }, this.options.requestTimeout); this.pendingRequests.set(id, { resolve: resolve as (result: unknown) => void, reject, timeout, }); const message: MCPMessage = { id, type: 'request', method, params, }; this.ws!.send(JSON.stringify(message)); }); } } // ============================================================================ // Singleton Instance // ============================================================================ let globalMCPConnector: MCPConnector | null = null; export function getMCPConnector(options?: MCPConnectorOptions): MCPConnector { if (!globalMCPConnector) { globalMCPConnector = new MCPConnector(options); } return globalMCPConnector; } export function destroyMCPConnector(): void { if (globalMCPConnector) { globalMCPConnector.disconnect(); globalMCPConnector = null; } }