/** * Mandu MCP Server v2 * * DNA 기능 통합: * - DNA-001: 플러그인 기반 도구 등록 * - DNA-006: 설정 핫 리로드 * - DNA-007: 에러 추출 및 분류 * - DNA-008: 구조화된 로깅 * - DNA-016: Pre/Post 도구 훅 */ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, type CallToolResult, } from "@modelcontextprotocol/sdk/types.js"; import { loadManduConfig, type ManduConfig } from "@mandujs/core"; import { startWatcher } from "@mandujs/core/watcher"; // DNA-001: 플러그인 기반 도구 레지스트리 import { mcpToolRegistry } from "./registry/mcp-tool-registry.js"; import { registerBuiltinTools, getToolsSummary } from "./tools/index.js"; // DNA-007: 에러 처리 import type { ToolExecutor} from "./executor/tool-executor.js"; import { createToolExecutor } from "./executor/tool-executor.js"; // DNA-008: 로깅 통합 import { setupMcpLogging, teardownMcpLogging } from "./logging/mcp-transport.js"; // DNA-016: 훅 시스템 import { mcpHookRegistry, registerDefaultMcpHooks } from "./hooks/mcp-hooks.js"; // DNA-006: 설정 핫 리로드 import { startMcpConfigWatcher, type McpConfigWatcher } from "./hooks/config-watcher.js"; // Prompts import { manduPrompts, getPromptResult } from "./prompts.js"; // New top-level resources import { manduResourceDefinitions, manduResourceHandlers } from "./new-resources.js"; // 기존 컴포넌트 import { resourceHandlers, resourceDefinitions } from "./resources/handlers.js"; import { findProjectRoot } from "./utils/project.js"; import { ActivityMonitor } from "./activity-monitor.js"; import { type McpProfile, resolveMcpProfile } from "./profiles.js"; /** * MCP 서버 버전 */ const MCP_VERSION = "0.12.0"; /** * Phase 17 — heap heartbeat interval (ms). Every tick we log * `Bun.memoryUsage()` + RSS / heapUsed / external + uptime so operators * tailing stderr can spot runaway growth without hooking a debugger. * * 5 minutes strikes a balance between "noisy enough to catch slow leaks" * and "not spammy enough to drown the log" (288 lines/day). Override * with `MANDU_MCP_HEAP_INTERVAL_MS` when running under stress tests. */ const DEFAULT_HEAP_LOG_INTERVAL_MS = 5 * 60 * 1000; /** * Emit a single heap-usage line to stderr. Called once on startup and * again on every heartbeat tick. * * Format is plain KV — easy to grep / pipe through `awk`: * * [MCP heap] rss=142MB heapUsed=56MB heapTotal=80MB external=3MB uptime=310s * * Uses `Bun.memoryUsage()` when available (returns RSS in bytes + JSC * gauges); falls back to `process.memoryUsage()` on Node test runners. * Errors are swallowed — a memory probe must never take down the server. */ export function logMcpHeapUsage(label: string = "heap"): void { try { const mem = process.memoryUsage(); const bunGlobal = (globalThis as { Bun?: { memoryUsage?: () => Record } }).Bun; let rss = mem.rss; let external = mem.external; if (bunGlobal?.memoryUsage) { try { const bunMem = bunGlobal.memoryUsage(); if (typeof bunMem.rss === "number") rss = bunMem.rss; if (typeof bunMem.external === "number") external = bunMem.external; } catch { // fall back to process.memoryUsage values } } const mb = (n: number) => `${Math.round(n / 1024 / 1024)}MB`; const uptime = Math.round(process.uptime()); console.error( `[MCP ${label}] rss=${mb(rss)} heapUsed=${mb(mem.heapUsed)} heapTotal=${mb(mem.heapTotal)} external=${mb(external)} uptime=${uptime}s`, ); } catch { // best-effort — never propagate } } /** * ManduMcpServer v2 * * DNA 기능들을 통합한 MCP 서버 */ export class ManduMcpServer { private server: Server; private projectRoot: string; private monitor: ActivityMonitor; private config?: ManduConfig; private configWatcher?: McpConfigWatcher; private toolExecutor: ToolExecutor; private profile: McpProfile; /** * Phase 17 — 5-minute heap-usage heartbeat. Lazily started in `run()` * and cleared by `stop()`. Disabled with `MANDU_MCP_HEAP_INTERVAL_MS=0`. */ private heapLogTimer: ReturnType | null = null; constructor(projectRoot: string) { this.projectRoot = projectRoot; this.monitor = new ActivityMonitor(projectRoot); // Resolve profile from environment variable (default: "agent-core") this.profile = resolveMcpProfile(process.env.MANDU_MCP_PROFILE); // MCP Server 초기화 this.server = new Server( { name: "mandu-mcp", version: MCP_VERSION, }, { capabilities: { tools: {}, resources: {}, prompts: {}, logging: {}, }, } ); // DNA-001: 플러그인 기반 도구 등록 registerBuiltinTools(projectRoot, this.server, this.monitor, { profile: this.profile, }); // DNA-008: 로깅 통합 setupMcpLogging({ consoleOutput: false }); // DNA-016: 기본 훅 등록 registerDefaultMcpHooks(); // Tool Executor 생성 this.toolExecutor = createToolExecutor({ projectRoot, logTool: (name, args, result, error) => this.monitor.logTool(name, args, result, error), logResult: (name, result) => this.monitor.logResult(name, result), }); // 핸들러 등록 this.registerToolHandlers(); this.registerResourceHandlers(); this.registerPromptHandlers(); } /** * 도구 핸들러 등록 (DNA-001 레지스트리 사용) */ private registerToolHandlers(): void { // 도구 목록 요청 this.server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: mcpToolRegistry.toToolDefinitions(), }; }); // 도구 실행 요청 this.server.setRequestHandler(CallToolRequestSchema, async (request, _extra) => { const { name, arguments: args } = request.params; // DNA-007 + DNA-016: Tool Executor로 실행 const result = await this.toolExecutor.execute(name, args || {}); return result.response as unknown as CallToolResult; }); } /** * Prompt handler registration */ private registerPromptHandlers(): void { this.server.setRequestHandler(ListPromptsRequestSchema, async () => { return { prompts: manduPrompts }; }); this.server.setRequestHandler(GetPromptRequestSchema, async (request) => { const { name, arguments: args } = request.params; const result = getPromptResult(name, args ?? {}); if (!result) { return { messages: [ { role: "user" as const, content: { type: "text" as const, text: `Unknown prompt: ${name}. Available prompts: ${manduPrompts.map((p) => p.name).join(", ")}`, }, }, ], }; } return result; }); } /** * 리소스 핸들러 등록 (기존 유지) */ private registerResourceHandlers(): void { const handlers = resourceHandlers(this.projectRoot); const newHandlers = manduResourceHandlers(this.projectRoot); // Merge resource definitions (new top-level + existing) const allResources = [...manduResourceDefinitions, ...resourceDefinitions]; this.server.setRequestHandler(ListResourcesRequestSchema, async () => { return { resources: allResources }; }); this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => { const { uri } = request.params; // Check new top-level resource handlers first const newHandler = newHandlers[uri]; if (newHandler) { try { const result = await newHandler(); return { contents: [result] }; } catch (error) { return { contents: [ { uri, mimeType: "application/json", text: JSON.stringify({ error: error instanceof Error ? error.message : String(error), }), }, ], }; } } // Fall through to existing resource handlers const handler = handlers[uri]; if (!handler) { // 동적 리소스 패턴 매칭 for (const [pattern, h] of Object.entries(handlers)) { if (pattern.includes("{") && matchResourcePattern(pattern, uri)) { const params = extractResourceParams(pattern, uri); const result = await h(params); return { contents: [ { uri, mimeType: "application/json", text: JSON.stringify(result, null, 2), }, ], }; } } return { contents: [ { uri, mimeType: "application/json", text: JSON.stringify({ error: `Unknown resource: ${uri}` }), }, ], }; } try { const result = await handler({}); return { contents: [ { uri, mimeType: "application/json", text: JSON.stringify(result, null, 2), }, ], }; } catch (error) { return { contents: [ { uri, mimeType: "application/json", text: JSON.stringify({ error: error instanceof Error ? error.message : String(error), }), }, ], }; } }); } /** * 서버 실행 */ async run(): Promise { // 설정 로드 try { this.config = await loadManduConfig(this.projectRoot); this.toolExecutor.updateConfig(this.config); } catch { // 설정 로드 실패 시 기본값 사용 console.error("[MCP] Config load failed, using defaults"); } // DNA-006: 설정 핫 리로드 시작 try { this.configWatcher = await startMcpConfigWatcher(this.projectRoot, { server: this.server, onReload: (newConfig) => { this.config = newConfig; this.toolExecutor.updateConfig(newConfig); }, onMcpConfigChange: async () => { // MCP 설정 변경 시 도구 재등록 가능 // 현재는 알림만 전송 }, }); } catch { console.error("[MCP] Config watcher start failed (non-critical)"); } // 서버 연결 const transport = new StdioServerTransport(); await this.server.connect(transport); // 모니터 시작 this.monitor.start(); // 와처 자동 시작 try { const watcher = await startWatcher({ rootDir: this.projectRoot }); watcher.onWarning((warning) => { this.monitor.logWatch( warning.level || "warn", warning.ruleId, warning.file, warning.message ); // MCP 클라이언트에 알림 this.server.sendLoggingMessage({ level: "warning", logger: "mandu-watch", data: { type: "watch_warning", severity: warning.level || "warn", ruleId: warning.ruleId, file: warning.file, message: warning.message, event: warning.event, agentAction: warning.agentAction || null, agentCommand: warning.agentCommand || null, }, }).catch(() => {}); }); this.monitor.logEvent("SYSTEM", "Watcher auto-started"); } catch { this.monitor.logEvent("SYSTEM", "Watcher auto-start failed (non-critical)"); } // 시작 로그 const summary = getToolsSummary(); console.error(`Mandu MCP Server v${MCP_VERSION} running`); console.error(` Project: ${this.projectRoot}`); console.error(` Profile: ${this.profile}`); console.error(` Tools: ${summary.total} (${summary.categories.join(", ")})`); // Phase 17 — start the heap heartbeat. Startup line is tagged // `startup` so operators can anchor before/after diffs to it. logMcpHeapUsage("startup"); this.startHeapHeartbeat(); } /** * Phase 17 — configure the heap-usage heartbeat. Reads * `MANDU_MCP_HEAP_INTERVAL_MS` for override (`0` = disabled). * Idempotent: repeat calls replace the previous timer. */ private startHeapHeartbeat(): void { if (this.heapLogTimer) { clearInterval(this.heapLogTimer); this.heapLogTimer = null; } const raw = process.env.MANDU_MCP_HEAP_INTERVAL_MS; let intervalMs = DEFAULT_HEAP_LOG_INTERVAL_MS; if (raw !== undefined) { const parsed = Number(raw); if (Number.isFinite(parsed) && parsed >= 0) intervalMs = parsed; } if (intervalMs === 0) return; // explicitly disabled this.heapLogTimer = setInterval(() => logMcpHeapUsage("heartbeat"), intervalMs); // `unref()` so the heartbeat never keeps the event loop alive on its // own — `stop()` must actively clear it, but a forgotten stop should // not hang the process. if (typeof this.heapLogTimer.unref === "function") { this.heapLogTimer.unref(); } } /** * 서버 종료 */ async stop(): Promise { // 설정 감시 중지 this.configWatcher?.stop(); // Phase 17 — stop the heap heartbeat. Safe to call when never started. if (this.heapLogTimer) { clearInterval(this.heapLogTimer); this.heapLogTimer = null; } // 로깅 해제 teardownMcpLogging(); // 모니터 종료 this.monitor.stop(); // 훅 정리 mcpHookRegistry.clear(); // 도구 레지스트리 정리 mcpToolRegistry.clear(); } /** * 현재 설정 반환 */ getConfig(): ManduConfig | undefined { return this.config; } /** * 도구 레지스트리 접근 */ getToolRegistry(): typeof mcpToolRegistry { return mcpToolRegistry; } /** * 훅 레지스트리 접근 */ getHookRegistry(): typeof mcpHookRegistry { return mcpHookRegistry; } } // ============================================ // 유틸리티 함수 // ============================================ /** * 리소스 패턴 매칭 */ function matchResourcePattern(pattern: string, uri: string): boolean { const regexPattern = pattern .split(/\{[^}]+\}/) .map(part => part.replace(/[.+*?^${}()|[\]\\]/g, "\\$&")) .join("([^/]+)"); const regex = new RegExp(`^${regexPattern}$`); return regex.test(uri); } /** * 리소스 파라미터 추출 */ function extractResourceParams(pattern: string, uri: string): Record { const paramNames: string[] = []; const regexPattern = pattern .split(/\{([^}]+)\}/) .map((part, index) => { if (index % 2 === 1) { paramNames.push(part); return "([^/]+)"; } return part.replace(/[.+*?^${}()|[\]\\]/g, "\\$&"); }) .join(""); const regex = new RegExp(`^${regexPattern}$`); const match = uri.match(regex); if (!match) return {}; const params: Record = {}; paramNames.forEach((name, index) => { params[name] = match[index + 1]; }); return params; } /** * MCP 서버 시작 */ export async function startServer(projectRoot?: string): Promise { const root = projectRoot || (await findProjectRoot()) || process.cwd(); const server = new ManduMcpServer(root); await server.run(); } // Re-exports export { mcpToolRegistry } from "./registry/mcp-tool-registry.js"; export { mcpHookRegistry } from "./hooks/mcp-hooks.js"; export { registerBuiltinTools } from "./tools/index.js";