/** * Mandu MCP - Brain Tools * * MCP tools for Brain functionality: * - mandu.brain.doctor: Guard failure analysis + patch suggestions * - mandu.watch.start: Start file watching * - mandu.watch.status: Get watch status * - mandu.brain.checkLocation: Check if file location is valid (v0.2) * - mandu.brain.checkImport: Check if imports are valid (v0.2) * - mandu.brain.architecture: Get project architecture rules (v0.2) */ import type { Tool } from "@modelcontextprotocol/sdk/types.js"; import type { Server } from "@modelcontextprotocol/sdk/server/index.js"; import type { ActivityMonitor } from "../activity-monitor.js"; import { loadManifest, runGuardCheck } from "@mandujs/core"; import { analyzeViolations, initializeBrain, getBrain, initializeArchitectureAnalyzer, getArchitectureAnalyzer, } from "@mandujs/core/brain"; import { generateJsonStatus, getWatcher, startWatcher, stopWatcher, } from "@mandujs/core/watcher"; import { getProjectPaths } from "../utils/project.js"; export const brainToolDefinitions: Tool[] = [ { name: "mandu.brain.doctor", description: "Analyze Guard failures and suggest patches. Returns early with no LLM call when " + "guard has no violations (passed: true). When violations are present and an LLM " + "tier is active (see mandu.brain.status), runs LLM-assisted analysis and returns " + "llmAssisted: true. Template-based analysis is always available as a fallback.", annotations: { readOnlyHint: true, }, inputSchema: { type: "object", properties: { useLLM: { type: "boolean", description: "Whether to use LLM for enhanced analysis (default: true if available)", }, }, required: [], }, }, { name: "mandu.watch.start", description: "Start file watching with architecture rule warnings. Watches for common mistakes and emits warnings (no blocking).", annotations: { readOnlyHint: false, }, inputSchema: { type: "object", properties: { debounceMs: { type: "number", description: "Debounce delay in milliseconds (default: 300)", }, }, required: [], }, }, { name: "mandu.watch.status", description: "Get the current watch status including recent warnings and active rules.", annotations: { readOnlyHint: true, }, inputSchema: { type: "object", properties: {}, required: [], }, }, { name: "mandu.watch.stop", description: "Stop file watching and clean up MCP notification subscriptions.", annotations: { readOnlyHint: false, }, inputSchema: { type: "object", properties: {}, required: [], }, }, // Architecture tools (v0.2) { name: "mandu.brain.checkLocation", description: "Check if a file location follows project architecture rules. Call this BEFORE creating or moving files to ensure proper placement.", annotations: { readOnlyHint: true, }, inputSchema: { type: "object", properties: { path: { type: "string", description: "File path to check (relative to project root)", }, content: { type: "string", description: "Optional file content for import validation", }, }, required: ["path"], }, }, { name: "mandu.brain.checkImport", description: "Check if imports in a file follow architecture rules. Call this to validate imports before adding them.", annotations: { readOnlyHint: true, }, inputSchema: { type: "object", properties: { sourceFile: { type: "string", description: "Source file path (relative to project root)", }, imports: { type: "array", items: { type: "string" }, description: "List of import paths to check", }, }, required: ["sourceFile", "imports"], }, }, { name: "mandu.brain.architecture", description: "Get the project architecture rules and folder structure. Use this to understand where to place new files.", annotations: { readOnlyHint: true, }, inputSchema: { type: "object", properties: { includeStructure: { type: "boolean", description: "Include folder tree in response (default: true)", }, }, required: [], }, }, { name: "mandu.brain.status", description: "Check which LLM adapter is active for brain (openai / anthropic / template) and whether auth tokens are present. Read-only — does not call an LLM or spawn subprocesses.", annotations: { readOnlyHint: true }, inputSchema: { type: "object", properties: {}, required: [], }, }, { name: "mandu.brain.login", description: "Authenticate the brain to an LLM provider. For openai, spawns `npx @openai/codex login` which opens the user's default browser to the OpenAI OAuth page; on approval, the token lands in ~/.codex/auth.json and this tool returns. Anthropic path uses the Mandu OAuth flow with a local loopback listener.", annotations: { readOnlyHint: false }, inputSchema: { type: "object", properties: { provider: { type: "string", enum: ["openai", "anthropic"], description: "Which provider to sign into. Default: openai.", }, waitMs: { type: "number", description: "How long to wait for auth.json to appear after spawning the OAuth flow. Default 180000 (3 min). Increase if the user takes longer to approve in the browser.", }, }, required: [], }, }, { name: "mandu.brain.logout", description: "Delete stored brain credentials for a provider. For openai, deletes the keychain-stored enterprise token only — the ~/.codex/auth.json owned by the Codex CLI is intentionally left in place (run `npx @openai/codex logout` to revoke that).", annotations: { readOnlyHint: false }, inputSchema: { type: "object", properties: { provider: { type: "string", enum: ["openai", "anthropic", "all"], description: "Which provider to log out of. Default: all.", }, }, required: [], }, }, ]; /** Module-level unsubscribe handle for MCP warning notifications */ let mcpWarningUnsubscribe: (() => void) | null = null; /** * Issue #237 Concern 4 — build a list of next-step suggestions for the * currently resolved brain tier. Exposed for unit tests so the tier → * suggestion mapping is pinned without spinning up a credential store. * * - openai / anthropic tiers → point at the LLM-heal loop + guard doctor. * - template tier (with login prompt) → point at `mandu brain login` for * higher quality. Unknown tiers return an empty list. * * Issue #235 follow-up — the local Ollama tier was removed; the only * non-cloud fallback is now `template` (deterministic, no LLM). */ export function buildBrainStatusSuggestions(activeTier: string): string[] { const suggestions: string[] = []; if (activeTier === "openai" || activeTier === "anthropic") { suggestions.push( "Run mandu.ate.auto_pipeline or mandu.ate.run followed by mandu.ate.heal to exercise the LLM-healing loop.", ); suggestions.push( "Call mandu.brain.doctor after a mandu.guard.check failure to get LLM-assisted diagnosis + patch suggestions.", ); } else if (activeTier === "template") { suggestions.push( "Run `mandu brain login --provider=openai` (or --provider=anthropic) to unlock higher-quality LLM-assisted heal + doctor output.", ); } return suggestions; } /** * #236 — surface a clear error when a stale `@mandujs/core` resolves * under `node_modules/@mandujs/mcp/node_modules/` (Bun's installer * sometimes lands an older nested copy even with `linker=hoisted`). * Without this check the user saw `getCredentialStore is not a * function` / `undefined is not a constructor` with no hint. */ function assertBrainAuthSurface(core: Record): void { const missing: string[] = []; if (typeof core.getCredentialStore !== "function") missing.push("getCredentialStore"); if (typeof core.resolveBrainAdapter !== "function") missing.push("resolveBrainAdapter"); if (typeof core.ChatGPTAuth !== "function") missing.push("ChatGPTAuth"); if (typeof core.AnthropicOAuthAdapter !== "function") missing.push("AnthropicOAuthAdapter"); if (typeof core.revokeConsent !== "function") missing.push("revokeConsent"); if (missing.length === 0) return; const pkgVersion = typeof core.__MANDU_CORE_VERSION__ === "string" ? core.__MANDU_CORE_VERSION__ : "unknown"; throw new Error( `[mandu-mcp] The resolved @mandujs/core (v${pkgVersion}) is missing brain-auth exports: ${missing.join( ", ", )}. ` + `This usually means Bun's installer placed a stale nested copy at ` + `node_modules/@mandujs/mcp/node_modules/@mandujs/core instead of hoisting to the top level. ` + `Fix: \`rm -rf node_modules bun.lock && bun install\` (or confirm linker=hoisted in bunfig.toml). ` + `See https://github.com/konamgil/mandu/issues/236 for details.`, ); } export function brainTools(projectRoot: string, server?: Server, monitor?: ActivityMonitor) { const paths = getProjectPaths(projectRoot); const handlers: Record) => Promise> = { "mandu.brain.doctor": async (args: Record) => { const { useLLM = false } = args as { useLLM?: boolean }; try { // Initialize Brain await initializeBrain(); const brain = getBrain(); const llmAvailable = await brain.isLLMAvailable(); // Load manifest const manifestResult = await loadManifest(paths.manifestPath); if (!manifestResult.success || !manifestResult.data) { return { error: "Failed to load manifest", details: manifestResult.errors, }; } // Run guard check const checkResult = await runGuardCheck( manifestResult.data, projectRoot ); if (checkResult.passed) { return { passed: true, message: "All guard checks passed - no violations found", llmAvailable, }; } // Analyze violations const analysis = await analyzeViolations(checkResult.violations, { useLLM: useLLM && brain.enabled && llmAvailable, }); return { passed: false, summary: analysis.summary, violationCount: analysis.violations.length, violations: analysis.violations.map((v) => ({ ruleId: v.ruleId, file: v.file, message: v.message, suggestion: v.suggestion, line: v.line, severity: v.severity || "error", })), patches: analysis.patches.map((p) => ({ file: p.file, type: p.type, description: p.description, ...(p.type === "command" ? { command: p.command } : {}), confidence: p.confidence, })), nextCommand: analysis.nextCommand, llmAssisted: analysis.llmAssisted, tip: "Run the suggested patches or nextCommand to fix violations", }; } catch (error) { return { error: "Doctor analysis failed", details: error instanceof Error ? error.message : "Unknown error", }; } }, "mandu.watch.start": async (args: Record) => { const { debounceMs } = args as { debounceMs?: number }; try { // Check if already watching const existingWatcher = getWatcher(); if (existingWatcher) { const status = existingWatcher.getStatus(); if (status.active) { return { success: false, message: "Watch is already running", status: JSON.parse(generateJsonStatus(status)), }; } } // Start watcher const watcher = await startWatcher({ rootDir: projectRoot, debounceMs, }); // Register MCP notification handler let notifications = false; if (server) { // Clean up previous subscription if (mcpWarningUnsubscribe) { mcpWarningUnsubscribe(); mcpWarningUnsubscribe = null; } mcpWarningUnsubscribe = watcher.onWarning((warning) => { // Log to activity monitor if (monitor) { monitor.logWatch( warning.level || "warn", warning.ruleId, warning.file, warning.message, ); } // Push logging message (MCP client receives in real-time) 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, timestamp: warning.timestamp.toISOString(), agentAction: warning.agentAction || null, agentCommand: warning.agentCommand || null, }, }).catch(() => {}); // Resource update notification server.sendResourceUpdated({ uri: "mandu://watch/warnings", }).catch(() => {}); }); notifications = true; } const status = watcher.getStatus(); return { success: true, message: "Watch started successfully", notifications: notifications ? "enabled" : "disabled", status: { active: status.active, rootDir: status.rootDir, fileCount: status.fileCount, startedAt: status.startedAt?.toISOString(), }, rules: [ "GENERATED_DIRECT_EDIT - Generated 파일 직접 수정 감지", "WRONG_SLOT_LOCATION - 잘못된 위치의 Slot 파일 감지", "SLOT_NAMING - Slot 파일 네이밍 규칙", "CONTRACT_NAMING - Contract 파일 네이밍 규칙", "FORBIDDEN_IMPORT - Generated 파일의 금지된 import 감지", "SLOT_MODIFIED - Slot 파일 수정 감지 (info)", ], logFile: ".mandu/watch.log", tip: "Run `tail -f .mandu/watch.log` in another terminal for real-time warnings.", }; } catch (error) { return { error: "Failed to start watch", details: error instanceof Error ? error.message : "Unknown error", }; } }, "mandu.watch.status": async () => { try { const watcher = getWatcher(); if (!watcher) { return { active: false, message: "Watch is not running", tip: "Use mandu.watch.start to begin watching", }; } const status = watcher.getStatus(); return { active: status.active, rootDir: status.rootDir, fileCount: status.fileCount, startedAt: status.startedAt?.toISOString() || null, uptime: status.startedAt ? Math.floor((Date.now() - status.startedAt.getTime()) / 1000) : 0, recentWarnings: status.recentWarnings.map((w) => ({ ruleId: w.ruleId, file: w.file, message: w.message, event: w.event, timestamp: w.timestamp.toISOString(), })), warningCount: status.recentWarnings.length, }; } catch (error) { return { error: "Failed to get watch status", details: error instanceof Error ? error.message : "Unknown error", }; } }, "mandu.watch.stop": async () => { try { // Clean up MCP notification subscription if (mcpWarningUnsubscribe) { mcpWarningUnsubscribe(); mcpWarningUnsubscribe = null; } await stopWatcher(); return { success: true, message: "Watch stopped and notifications cleaned up", }; } catch (error) { return { error: "Failed to stop watch", details: error instanceof Error ? error.message : "Unknown error", }; } }, // Architecture tools (v0.2) "mandu.brain.checkLocation": async (args: Record) => { const { path: filePath, content } = args as { path: string; content?: string; }; try { // Initialize analyzer if needed let analyzer = getArchitectureAnalyzer(); if (!analyzer) { await initializeBrain(); analyzer = initializeArchitectureAnalyzer(projectRoot); } const result = await analyzer.checkLocation({ path: filePath, content, }); if (result.allowed) { return { allowed: true, message: `✅ '${filePath}'는 올바른 위치입니다`, tip: "파일을 생성해도 됩니다", }; } return { allowed: false, violations: result.violations.map((v) => ({ rule: v.ruleId, message: v.message, severity: v.severity, })), suggestion: result.suggestion, recommendedPath: result.recommendedPath, tip: result.recommendedPath ? `권장 경로: ${result.recommendedPath}` : "위반 사항을 확인하고 적절한 위치에 파일을 생성하세요", }; } catch (error) { return { error: "Location check failed", details: error instanceof Error ? error.message : "Unknown error", }; } }, "mandu.brain.checkImport": async (args: Record) => { const { sourceFile, imports } = args as { sourceFile: string; imports: string[]; }; try { let analyzer = getArchitectureAnalyzer(); if (!analyzer) { await initializeBrain(); analyzer = initializeArchitectureAnalyzer(projectRoot); } const result = await analyzer.checkImports({ sourceFile, imports, }); if (result.allowed) { return { allowed: true, message: "✅ 모든 import가 허용됩니다", checkedImports: imports, }; } return { allowed: false, violations: result.violations.map((v) => ({ import: v.import, reason: v.reason, suggestion: v.suggestion, })), tip: "금지된 import를 제거하거나 대안을 사용하세요", }; } catch (error) { return { error: "Import check failed", details: error instanceof Error ? error.message : "Unknown error", }; } }, "mandu.brain.architecture": async (args: Record) => { const { includeStructure = true } = args as { includeStructure?: boolean; }; try { let analyzer = getArchitectureAnalyzer(); if (!analyzer) { await initializeBrain(); analyzer = initializeArchitectureAnalyzer(projectRoot); } const config = analyzer.getConfig(); const structure = includeStructure ? await analyzer.getProjectStructure() : null; return { folders: Object.entries(config.folders || {}).map(([key, rule]) => { const folderRule = typeof rule === "string" ? { pattern: key, description: rule } : rule; return { pattern: folderRule.pattern, description: folderRule.description, readonly: folderRule.readonly || false, allowedFiles: folderRule.allowedFiles, }; }), importRules: (config.imports || []).map((rule) => ({ source: rule.source, forbid: rule.forbid, allow: rule.allow, reason: rule.reason, })), namingRules: (config.naming || []).map((rule) => ({ folder: rule.folder, pattern: rule.filePattern, description: rule.description, examples: rule.examples, })), structure: structure ? { rootDir: structure.rootDir, folders: structure.folders, indexedAt: structure.indexedAt, } : null, tip: "이 규칙을 따라 파일을 생성하세요. mandu.brain.checkLocation으로 검증할 수 있습니다.", }; } catch (error) { return { error: "Failed to get architecture", details: error instanceof Error ? error.message : "Unknown error", }; } }, }; // #235 followup — brain auth tools (status / login / logout). handlers["mandu.brain.status"] = async () => { const core = await import("@mandujs/core/brain"); assertBrainAuthSurface(core); const store = core.getCredentialStore(); const resolution = await core.resolveBrainAdapter({ adapter: "auto", credentialStore: store, projectRoot, }); // Check ChatGPT session token (managed by @openai/codex, not the keychain). const chatgpt = new core.ChatGPTAuth(); const chatgptFile = chatgpt.locateAuthFile(); const providers: Record = {}; for (const provider of ["openai", "anthropic"] as const) { const token = await store.load(provider); providers[provider] = token ? { logged_in: true, source: "keychain", model: token.default_model ?? null, expires_at: token.expires_at ? new Date(token.expires_at * 1000).toISOString() : null, last_used_at: token.last_used_at ?? null, } : provider === "openai" && chatgptFile ? { logged_in: true, source: "chatgpt_session", auth_file: chatgptFile, note: "Managed by `@openai/codex` CLI. Mandu reads + auto-refreshes.", } : { logged_in: false }; } // Issue #237 Concern 4 — surface next-step suggestions keyed to the // active tier so agents can find the LLM invocation paths without // grep-archaeology. LLM tiers point at ate.heal / brain.doctor; // template tier points at `mandu brain login` for an upgrade. const suggestions = buildBrainStatusSuggestions(resolution.resolved); // Issue #235 follow-up — the resolver now exposes `needsLogin: true` // when the only reason it fell back to template is that no cloud // token exists yet. Surface it so agents can short-circuit a chat // round-trip to ask the user for `mandu brain login`. const needsLogin = resolution.needsLogin === true; const loginHint = needsLogin ? "No cloud token detected — run `mandu brain login --provider=openai` (or --provider=anthropic) to unlock LLM-backed adapters." : undefined; return { content: [ { type: "text", text: JSON.stringify( { active_tier: resolution.resolved, reason: resolution.reason, backend: store.backendName, needs_login: needsLogin, login_hint: loginHint, providers, suggestions, }, null, 2, ), }, ], }; }; handlers["mandu.brain.login"] = async (args) => { const { provider = "openai", waitMs = 180000 } = args as { provider?: "openai" | "anthropic"; waitMs?: number; }; if (provider === "openai") { const core = await import("@mandujs/core/brain"); assertBrainAuthSurface(core); const auth = new core.ChatGPTAuth(); const existing = auth.locateAuthFile(); if (existing) { return { content: [ { type: "text", text: JSON.stringify( { ok: true, provider: "openai", already_authenticated: true, auth_file: existing, note: "ChatGPT session already present. Call mandu.brain.logout + mandu.brain.login again to re-authenticate.", }, null, 2, ), }, ], }; } // Spawn `npx @openai/codex login` detached from the MCP process. // Codex itself opens the user's default browser (`start` on // Windows, `open` on macOS, `xdg-open` on Linux) — no TTY needed // on our side. We poll for ~/.codex/auth.json to appear and // return once it does. const { spawn } = await import("node:child_process"); const child = spawn("npx", ["-y", "@openai/codex", "login"], { cwd: projectRoot, detached: false, stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32", }); let stdoutBuffer = ""; let stderrBuffer = ""; child.stdout?.on("data", (d) => { stdoutBuffer += d.toString(); }); child.stderr?.on("data", (d) => { stderrBuffer += d.toString(); }); const deadline = Date.now() + Math.max(15_000, Math.min(waitMs, 600_000)); let file: string | null = null; while (Date.now() < deadline) { file = auth.locateAuthFile(); if (file) break; await new Promise((r) => setTimeout(r, 1000)); } // Kill the codex process if it's still running (normally it exits // on its own once auth.json is written). if (!child.killed) { try { child.kill(); } catch { /* ignore */ } } const urlMatch = stdoutBuffer.match( /https:\/\/auth\.openai\.com\/oauth\/authorize\?[^\s]+/, ); return { content: [ { type: "text", text: JSON.stringify( { ok: Boolean(file), provider: "openai", auth_file: file, oauth_url: urlMatch ? urlMatch[0] : undefined, stdout_tail: stdoutBuffer.slice(-500), stderr_tail: stderrBuffer.slice(-500), note: file ? "auth.json written; Mandu brain will now use the OpenAI tier." : "No auth.json detected before waitMs expired. If the OAuth URL is present above, open it in a browser; otherwise rerun with a larger waitMs or run `npx @openai/codex login` in your own terminal.", }, null, 2, ), }, ], }; } // Anthropic — Mandu-managed OAuth loopback flow. const core = await import("@mandujs/core/brain"); assertBrainAuthSurface(core); try { const adapter = new core.AnthropicOAuthAdapter({ credentialStore: core.getCredentialStore(), projectRoot, strict: true, skipConsent: true, }); const token = await adapter.login({}); return { content: [ { type: "text", text: JSON.stringify( { ok: true, provider: "anthropic", model: token.default_model ?? null, expires_at: token.expires_at ? new Date(token.expires_at * 1000).toISOString() : null, }, null, 2, ), }, ], }; } catch (err) { return { content: [ { type: "text", text: JSON.stringify( { ok: false, provider: "anthropic", error: err instanceof Error ? err.message : String(err), }, null, 2, ), }, ], }; } }; handlers["mandu.brain.logout"] = async (args) => { const { provider = "all" } = args as { provider?: "openai" | "anthropic" | "all"; }; const core = await import("@mandujs/core/brain"); assertBrainAuthSurface(core); const store = core.getCredentialStore(); const targets = provider === "all" ? (["openai", "anthropic"] as const) : ([provider] as const); for (const p of targets) { await store.delete(p); await core.revokeConsent(p, projectRoot); } return { content: [ { type: "text", text: JSON.stringify( { ok: true, logged_out: targets, note: targets.includes("openai") ? "Keychain-stored openai token cleared. To revoke the Codex CLI session (~/.codex/auth.json), run `npx @openai/codex logout`." : undefined, }, null, 2, ), }, ], }; }; // Backward-compatible aliases (deprecated) handlers["mandu_doctor"] = handlers["mandu.brain.doctor"]; handlers["mandu_watch_start"] = handlers["mandu.watch.start"]; handlers["mandu_watch_stop"] = handlers["mandu.watch.stop"]; handlers["mandu_watch_status"] = handlers["mandu.watch.status"]; handlers["mandu_check_location"] = handlers["mandu.brain.checkLocation"]; handlers["mandu_check_import"] = handlers["mandu.brain.checkImport"]; handlers["mandu_get_architecture"] = handlers["mandu.brain.architecture"]; return handlers; }