/**
 * src/mcp/manager.ts
 *
 * MCP (Model Context Protocol) client manager.
 *
 * Reads <dataDir>/mcp.json (Claude Desktop / Cursor compatible format),
 * spawns each configured server over stdio, lists its tools, and exposes
 * them through Muassis's existing tool framework. The agent doesn't know
 * which tools are "native" vs "MCP" — they all show up in the toolbelt
 * and dispatch correctly based on the registered backing.
 *
 * Config format (drop-in compatible with Claude Desktop's mcp.json):
 * {
 *   "mcpServers": {
 *     "playwright": { "command": "npx", "args": ["@playwright/mcp"] },
 *     "github": {
 *       "command": "npx",
 *       "args": ["-y", "@modelcontextprotocol/server-github"],
 *       "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_..." }
 *     }
 *   }
 * }
 *
 * Tools from MCP servers are auto-prefixed with `mcp_<server>_` to avoid
 * collisions with native tools. The LLM sees the prefixed name and routes
 * back through callTool() here.
 */
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { join, dirname, isAbsolute } from 'node:path';
import { execSync } from 'node:child_process';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { createChildLogger } from '../utils/logger.js';
import { getDataDir } from '../utils/data-dir.js';
const log = createChildLogger('mcp:manager');
class MCPManager {
    servers = new Map();
    configPath;
    constructor() {
        this.configPath = join(getDataDir(), 'mcp.json');
    }
    /** Path to the persistent MCP config file. */
    getConfigPath() {
        return this.configPath;
    }
    /** Read the config from disk, returning an empty config if missing. */
    readConfig() {
        if (!existsSync(this.configPath))
            return { mcpServers: {} };
        try {
            const raw = readFileSync(this.configPath, 'utf-8');
            const parsed = JSON.parse(raw);
            if (!parsed.mcpServers || typeof parsed.mcpServers !== 'object') {
                return { mcpServers: {} };
            }
            return parsed;
        }
        catch (err) {
            log.error({ error: err.message, path: this.configPath }, 'Failed to parse mcp.json');
            return { mcpServers: {} };
        }
    }
    /** Write the config to disk, creating directories as needed. */
    writeConfig(config) {
        mkdirSync(getDataDir(), { recursive: true });
        writeFileSync(this.configPath, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
    }
    /** Register a new server in the config. Does NOT spawn — call init() to start. */
    addServer(name, config) {
        const cfg = this.readConfig();
        cfg.mcpServers[name] = config;
        this.writeConfig(cfg);
        log.info({ name, command: config.command }, 'MCP server registered');
    }
    /**
     * Hot-attach a freshly-registered server: writes to mcp.json, spawns the
     * process, connects the MCP client, and refreshes the tool catalogue so
     * the brain sees the new tools on the very next message. No restart.
     *
     * Returns the number of new tools surfaced (0 if the server is already
     * connected with the same config).
     */
    async addAndConnectServer(name, config) {
        // Replace mode: stop any old instance with the same name, then spawn.
        const wasRunning = this.servers.has(name);
        if (wasRunning) {
            try {
                await this.stopServer(name);
            }
            catch { /* ignore */ }
        }
        this.addServer(name, config);
        await this.spawnServer(name, config);
        const running = this.servers.get(name);
        const toolCount = running?.tools.length ?? 0;
        log.info({ name, toolCount, replaced: wasRunning }, '🔌 MCP server hot-attached');
        return { toolCount, alreadyRunning: wasRunning };
    }
    /**
     * Snapshot of currently-connected servers + their tool catalogue. Used by
     * the list_mcp_tools brain tool so the agent can answer "what can you do
     * with X?" without guessing.
     */
    listConnectedServers() {
        const out = [];
        for (const [name, srv] of this.servers.entries()) {
            out.push({
                name,
                toolCount: srv.tools.length,
                tools: srv.tools.map((t) => t.name),
            });
        }
        return out;
    }
    /** Remove a server from the config and stop it if running. */
    async removeServer(name) {
        const cfg = this.readConfig();
        if (!cfg.mcpServers[name])
            return false;
        delete cfg.mcpServers[name];
        this.writeConfig(cfg);
        await this.stopServer(name);
        log.info({ name }, 'MCP server removed');
        return true;
    }
    /**
     * Default MCP servers shipped with the gateway. These are auto-written
     * to mcp.json on first boot if no config exists, so the agent has real
     * tools out-of-the-box without any user setup. Each package is a
     * runtime dependency so `npx -y` finds it in local node_modules
     * instantly — no network fetch on first spawn.
     *
     * Only servers that work without API keys get auto-enabled. Token-
     * gated ones (github, slack, etc.) stay opt-in via `muassis mcp install`.
     */
    static DEFAULT_SERVERS = {
        playwright: {
            command: 'npx',
            args: ['-y', '@playwright/mcp@latest'],
            description: 'Browser automation — navigate, click, screenshot, scrape any website',
        },
        memory: {
            command: 'npx',
            args: ['-y', '@modelcontextprotocol/server-memory'],
            description: 'Persistent KV memory across conversations',
        },
        'sequential-thinking': {
            command: 'npx',
            args: ['-y', '@modelcontextprotocol/server-sequential-thinking'],
            description: 'Structured chain-of-thought scaffolding',
        },
        // (v2.5.5) NotebookLM bridge — research-grounded slide content from
        // user-uploaded source documents. Optional and requires the user to
        // sign in to NotebookLM via the browser-automation profile on first
        // use. Disabled by `enabled: false` so it doesn't try to spawn on
        // boot until the user opts in via `muassis mcp enable notebooklm`.
        notebooklm: {
            command: 'npx',
            args: ['-y', '@notebooklm/mcp'],
            description: 'NotebookLM — research-grounded slide and brief content from user-uploaded sources. Requires one-time Google sign-in. Enable with `muassis mcp enable notebooklm`.',
            disabled: true,
        },
    };
    /**
     * Write the default MCP server set to mcp.json. Called automatically on
     * first boot when no config exists. If the user later does
     * `muassis mcp remove <name>`, that change persists — we only auto-write
     * when mcp.json doesn't exist at all (not when it's empty).
     */
    bootstrapDefaultsIfMissing() {
        if (existsSync(this.configPath))
            return false;
        log.info({ path: this.configPath, defaults: Object.keys(MCPManager.DEFAULT_SERVERS) }, 'First boot — auto-writing default MCP config');
        this.writeConfig({ mcpServers: { ...MCPManager.DEFAULT_SERVERS } });
        return true;
    }
    /**
     * Spawn all enabled servers in the config. Best-effort: a single broken
     * server doesn't take down the whole gateway. Failed servers are logged
     * and skipped.
     */
    async init() {
        const wroteDefaults = this.bootstrapDefaultsIfMissing();
        const cfg = this.readConfig();
        const names = Object.keys(cfg.mcpServers);
        if (names.length === 0) {
            log.info('No MCP servers configured (mcp.json empty)');
            return;
        }
        log.info({ servers: names, wroteDefaults }, wroteDefaults
            ? 'Starting MCP servers (defaults bootstrapped — first boot)'
            : 'Starting MCP servers');
        const results = await Promise.allSettled(names
            .filter(n => !cfg.mcpServers[n].disabled)
            .map(n => this.spawnServer(n, cfg.mcpServers[n])));
        const ok = results.filter(r => r.status === 'fulfilled').length;
        const failed = results.length - ok;
        log.info({ ok, failed, total: results.length }, 'MCP servers initialization complete');
    }
    async spawnServer(name, config) {
        try {
            // (v3 bundled-sidecar fix) Inside the .app, `npx` isn't on PATH and the
            // bundled node has no `npm` neighbor. Every default MCP server (memory,
            // sequential-thinking, playwright) is already in node_modules, so we
            // resolve those locally and run them with the same node that's running
            // the gateway — zero network, zero PATH dependency.
            //
            // For non-bundled packages, we fall back to the configured command but
            // first try to resolve `npx` against an extended PATH (homebrew + nvm
            // locations) so installs that happen on a customer who DOES have npm
            // still work.
            const resolved = this.resolveSpawnCommand(config);
            const transport = new StdioClientTransport({
                command: resolved.command,
                args: resolved.args,
                env: this.buildSpawnEnv(config.env),
            });
            const client = new Client({ name: `muassis-${name}`, version: '1.0.0' }, { capabilities: {} });
            await client.connect(transport);
            const toolsResult = await client.listTools();
            const tools = toolsResult.tools ?? [];
            this.servers.set(name, { name, client, tools, transport });
            log.info({ name, toolCount: tools.length, tools: tools.map(t => t.name), command: resolved.command, resolved: resolved.method }, 'MCP server connected');
        }
        catch (err) {
            log.error({ name, command: config.command, error: err instanceof Error ? err.message : String(err) }, 'Failed to start MCP server (skipping)');
        }
    }
    /**
     * Resolve the actual command/args to spawn for a server config. Handles
     * three cases:
     *
     *   1. Bundled package — config says `npx -y <pkg>` and <pkg> is already in
     *      the gateway's node_modules. We rewrite to `<bundled-node> <bin.js>`
     *      and skip npx entirely. This is what makes the defaults (memory,
     *      sequential-thinking, playwright) work in the .app.
     *
     *   2. npx fallback — config asks for npx but the package isn't bundled.
     *      We search an extended PATH for npx (homebrew, nvm, etc.) since the
     *      .app's spawn env has a minimal default PATH.
     *
     *   3. Absolute / non-npx command — passed through as-is. The user knows
     *      what they're doing.
     */
    resolveSpawnCommand(config) {
        const args = config.args ?? [];
        if (config.command === 'npx') {
            // Strip the `-y` / `--yes` flag and any other dash-prefixed flags to
            // isolate the package name. `npx -y @foo/bar` → pkg = `@foo/bar`.
            const pkgIndex = args.findIndex(a => !a.startsWith('-'));
            if (pkgIndex >= 0) {
                const pkg = args[pkgIndex].replace(/@latest$/, '');
                const bundled = this.resolveBundledPackageBin(pkg);
                if (bundled) {
                    // Forward any args AFTER the package name (e.g. `--port 9000`).
                    const extra = args.slice(pkgIndex + 1);
                    return {
                        command: process.execPath,
                        args: [bundled, ...extra],
                        method: 'bundled',
                    };
                }
            }
            const npxOnPath = findNpxOnExtendedPath();
            if (npxOnPath) {
                return { command: npxOnPath, args, method: 'npx-extended-path' };
            }
            // Last resort: pass through. Spawn will fail with ENOENT; the error
            // surface in spawnServer() makes it visible.
        }
        return { command: config.command, args, method: 'passthrough' };
    }
    /**
     * Look up a package's `bin` entry inside the gateway's node_modules and
     * return the absolute path to its JS entry, or null if not bundled.
     * Strips `@latest` / version suffixes from the package name.
     */
    resolveBundledPackageBin(pkg) {
        const cleanPkg = pkg.replace(/@(?!.*\/)(?!latest).+$/, '').replace(/@latest$/, '');
        try {
            // Use require.resolve to find package.json with strict matching.
            const req = createRequireForGateway();
            const pjPath = req.resolve(`${cleanPkg}/package.json`);
            const pj = JSON.parse(readFileSync(pjPath, 'utf-8'));
            const pkgDir = dirname(pjPath);
            let binRel;
            if (typeof pj.bin === 'string') {
                binRel = pj.bin;
            }
            else if (pj.bin && typeof pj.bin === 'object') {
                // Prefer the first bin entry (most MCP servers ship just one).
                binRel = Object.values(pj.bin)[0];
            }
            const target = binRel ?? pj.main;
            if (!target)
                return null;
            const abs = isAbsolute(target) ? target : join(pkgDir, target);
            return existsSync(abs) ? abs : null;
        }
        catch {
            return null;
        }
    }
    /**
     * Compose the env passed to the spawned MCP server. We always merge in
     * the parent process env plus an extended PATH so child processes (e.g.
     * `playwright-mcp` shelling out to chromium) can locate auxiliary tools.
     */
    buildSpawnEnv(serverEnv) {
        const base = { ...process.env };
        base.PATH = extendedPath(base.PATH);
        if (serverEnv)
            Object.assign(base, serverEnv);
        return base;
    }
    /** Tear down a single server cleanly. */
    async stopServer(name) {
        const s = this.servers.get(name);
        if (!s)
            return;
        try {
            await s.client.close();
        }
        catch (err) {
            log.warn({ name, error: err.message }, 'Error closing MCP client');
        }
        this.servers.delete(name);
    }
    /** Tear down all servers (called at gateway shutdown). */
    async shutdown() {
        await Promise.allSettled(Array.from(this.servers.keys()).map(n => this.stopServer(n)));
    }
    /**
     * Flatten every running MCP tool into a Muassis ToolDefinition. Tool
     * names are prefixed with `mcp_<server>_` so they don't collide with
     * native tools like read_file. The LLM only sees the flat list — it
     * doesn't know which server backs which tool.
     *
     * We aggressively filter junk to keep the LLM's toolbelt focused:
     *   1. Demo-fixture tools (echo, get-sum, simulate-research-query,
     *      get-tiny-image, …) come from the modelcontextprotocol/servers
     *      "everything" server which is a TEST harness, not a useful
     *      production server. Easy to install accidentally; useless to
     *      keep around. We filter them by name patterns.
     *   2. Duplicate tool names across multiple servers (which happens
     *      when the user installs two paths to the same upstream package).
     *      Keep only the first.
     *
     * Net effect on a typical setup: 79 tools → ~45, system prompt drops
     * by ~5-7K tokens, model can actually decide.
     */
    getToolDefinitions() {
        const defs = [];
        const seen = new Set();
        for (const [name, server] of this.servers) {
            for (const tool of server.tools) {
                if (isDemoToolName(tool.name))
                    continue;
                const prefixed = `mcp_${sanitizeName(name)}_${sanitizeName(tool.name)}`;
                // Dedup by tool *name* (not prefixed) so the same tool from two
                // duplicate-server installs doesn't appear twice.
                if (seen.has(tool.name))
                    continue;
                seen.add(tool.name);
                defs.push({
                    type: 'function',
                    function: {
                        name: prefixed,
                        description: tool.description ?? `${name}: ${tool.name}`,
                        parameters: tool.inputSchema ?? { type: 'object', properties: {} },
                    },
                });
            }
        }
        return defs;
    }
    /**
     * Look up the (server, original-tool-name) pair for a prefixed name.
     * Returns null if the name doesn't match any registered MCP tool.
     */
    resolveToolName(prefixedName) {
        if (!prefixedName.startsWith('mcp_'))
            return null;
        const rest = prefixedName.slice(4);
        // Match against actual server names so we handle servers whose names
        // happen to contain underscores correctly.
        for (const [name, server] of this.servers) {
            const sanitized = sanitizeName(name);
            const prefix = sanitized + '_';
            if (rest.startsWith(prefix)) {
                const toolPart = rest.slice(prefix.length);
                const tool = server.tools.find(t => sanitizeName(t.name) === toolPart);
                if (tool)
                    return { server: name, toolName: tool.name };
            }
        }
        return null;
    }
    /** Dispatch a tool call to the right MCP server. */
    async callTool(prefixedName, args) {
        const resolved = this.resolveToolName(prefixedName);
        if (!resolved) {
            return { success: false, output: `Unknown MCP tool: ${prefixedName}` };
        }
        const server = this.servers.get(resolved.server);
        if (!server) {
            return { success: false, output: `MCP server '${resolved.server}' is not running` };
        }
        try {
            const result = await server.client.callTool({
                name: resolved.toolName,
                arguments: args,
            });
            const content = result.content ?? [];
            const text = content
                .map(c => (c.type === 'text' && typeof c.text === 'string' ? c.text : ''))
                .filter(Boolean)
                .join('\n');
            const isError = result.isError === true;
            const friendly = isError && /(Executable doesn't exist|browser.*not.*installed|playwright install)/i.test(text)
                ? text + '\n\n💡 Browser binary missing. The agent (or user) can fix it by running: `npx playwright install chromium-headless-shell` once. Set MUASSIS_FORCE_BROWSER_INSTALL=1 in env to retry the postinstall.'
                : text;
            return {
                success: !isError,
                output: friendly || (isError ? 'MCP tool returned error' : 'MCP tool completed (no text output)'),
                data: result,
            };
        }
        catch (err) {
            return {
                success: false,
                output: `MCP tool '${prefixedName}' failed: ${err instanceof Error ? err.message : String(err)}`,
            };
        }
    }
    /** List currently-running servers + their tool counts (for `muassis mcp list`). */
    status() {
        const cfg = this.readConfig();
        const all = Object.keys(cfg.mcpServers);
        return all.map(name => {
            const s = this.servers.get(name);
            return {
                name,
                running: !!s,
                toolCount: s?.tools.length ?? 0,
                tools: s?.tools.map(t => t.name) ?? [],
            };
        });
    }
}
/** Tool / server names need to be tool-call-safe — alphanumeric + underscore. */
function sanitizeName(s) {
    return s.replace(/[^a-zA-Z0-9]/g, '_');
}
/**
 * createRequire bound to this module's location so package.json resolution
 * searches the gateway's node_modules — not the bundled-node binary's own
 * node_modules (which doesn't exist on the .app sidecar).
 */
import { createRequire as _createRequire } from 'node:module';
function createRequireForGateway() {
    // import.meta.url under ESM resolves to .../dist/mcp/manager.js, so
    // require('foo/package.json') walks up through dist/node_modules → root
    // node_modules and finds the bundled packages.
    return _createRequire(import.meta.url);
}
/**
 * Best-effort extra PATH locations on macOS / Linux where customers'
 * npx / git / node tend to live. The bundled .app launches with a minimal
 * /usr/bin:/bin PATH, which means `npx` is invisible unless we prepend
 * these. Windows is handled separately (PATH is usually fine inside .msi).
 */
const EXTRA_PATH_DIRS = [
    '/opt/homebrew/bin', // Apple Silicon Homebrew
    '/usr/local/bin', // Intel Homebrew + classic /usr/local
    '/usr/local/sbin',
    `${process.env.HOME ?? ''}/.nvm/versions/node`, // nvm root (children added below)
    `${process.env.HOME ?? ''}/.local/bin`,
    `${process.env.HOME ?? ''}/.volta/bin`,
    `${process.env.HOME ?? ''}/.fnm/aliases/default/bin`,
];
function extendedPath(existing) {
    const parts = (existing ?? '').split(':').filter(Boolean);
    const added = [];
    for (const dir of EXTRA_PATH_DIRS) {
        if (!dir)
            continue;
        if (parts.includes(dir))
            continue;
        if (existsSync(dir))
            added.push(dir);
    }
    // Also probe the latest nvm node bin if present.
    try {
        const nvmRoot = `${process.env.HOME ?? ''}/.nvm/versions/node`;
        if (existsSync(nvmRoot)) {
            const versions = require('node:fs').readdirSync(nvmRoot);
            const latest = versions.filter((v) => v.startsWith('v')).sort().pop();
            if (latest) {
                const bin = `${nvmRoot}/${latest}/bin`;
                if (existsSync(bin) && !parts.includes(bin))
                    added.push(bin);
            }
        }
    }
    catch { /* ignore */ }
    return [...parts, ...added].join(':');
}
/**
 * Locate an `npx` executable on the extended PATH. Returns absolute path or
 * null. Cached after first call so we don't shell out on every server
 * spawn.
 */
let _cachedNpxPath;
function findNpxOnExtendedPath() {
    if (_cachedNpxPath !== undefined)
        return _cachedNpxPath;
    const env = { ...process.env, PATH: extendedPath(process.env.PATH) };
    try {
        const which = process.platform === 'win32' ? 'where' : 'which';
        const out = execSync(`${which} npx`, { env, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
        const first = out.split(/\r?\n/)[0].trim();
        _cachedNpxPath = first || null;
    }
    catch {
        _cachedNpxPath = null;
    }
    return _cachedNpxPath;
}
/**
 * Recognize the demo/test tools shipped by the "everything" reference
 * MCP server. These exist to stress-test MCP clients, not to do real
 * work. Filtering them keeps the agent's toolbelt focused on tools
 * that actually accomplish something.
 */
function isDemoToolName(name) {
    const lower = name.toLowerCase();
    const demoNames = new Set([
        'echo',
        'get-annotated-message',
        'get-env',
        'get-resource-links',
        'get-resource-reference',
        'get-structured-content',
        'get-sum',
        'get-tiny-image',
        'gzip-file-as-resource',
        'toggle-simulated-logging',
        'toggle-subscriber-updates',
        'trigger-long-running-operation',
        'simulate-research-query',
    ]);
    if (demoNames.has(lower))
        return true;
    // Catch obvious "test" / "simulate" / "tiny" tool names from any
    // server that ships testing fixtures.
    if (/^(simulate-|toggle-simulated|get-tiny-)/.test(lower))
        return true;
    return false;
}
export const mcpManager = new MCPManager();
//# sourceMappingURL=manager.js.map