import { spawn } from 'node:child_process';
import { createInterface } from 'node:readline';
const CONNECTION_TIMEOUT_MS = 30_000;
const REQUEST_TIMEOUT_MS = 60_000;
const CLIENT_INFO = { name: 'bobbit', version: '0.1.6' };
const PROTOCOL_VERSION = '2024-11-05';
/**
 * Expand `${VAR}` patterns in a string using process.env.
 * Unresolved variables are replaced with empty string.
 */
function expandEnvVars(value) {
    return value.replace(/\$\{([^}]+)\}/g, (_match, varName) => {
        return process.env[varName] ?? '';
    });
}
/**
 * Expand env vars in all values of a config env record.
 */
function expandEnvRecord(env) {
    const result = {};
    for (const [key, value] of Object.entries(env)) {
        result[key] = expandEnvVars(value);
    }
    return result;
}
/**
 * MCP JSON-RPC 2.0 client supporting stdio and HTTP transports.
 */
export class McpClient {
    serverName;
    _connected = false;
    _config = null;
    _nextId = 1;
    // Stdio transport state
    _process = null;
    _readline = null;
    _pendingRequests = new Map();
    constructor(serverName) {
        this.serverName = serverName;
    }
    /** Whether the client is currently connected */
    get connected() {
        return this._connected;
    }
    /** Connect to MCP server. Spawns process (stdio) or validates URL (HTTP). Sends initialize handshake. */
    async connect(config) {
        this._config = config;
        if (config.command) {
            await this._connectStdio(config);
        }
        else if (config.url) {
            await this._connectHttp(config);
        }
        else {
            throw new Error(`[mcp:${this.serverName}] Config must have either 'command' (stdio) or 'url' (HTTP)`);
        }
    }
    /** Call tools/list and return tool definitions */
    async listTools() {
        this._assertConnected();
        const response = await this._sendRequest('tools/list', {});
        const result = response.result;
        return result?.tools ?? [];
    }
    /** Call tools/call with the given tool name and arguments */
    async callTool(name, args) {
        this._assertConnected();
        const response = await this._sendRequest('tools/call', { name, arguments: args });
        if (response.error) {
            const errMsg = typeof response.error === 'object'
                ? (response.error.message || JSON.stringify(response.error))
                : String(response.error);
            return {
                content: [{ type: 'text', text: errMsg }],
                isError: true,
            };
        }
        const result = response.result;
        if (result && !Array.isArray(result.content)) {
            this._log(`Warning: tools/call "${name}" returned result with non-array content: ${JSON.stringify(result).slice(0, 500)}`);
            return { content: [{ type: 'text', text: JSON.stringify(result) }], isError: false };
        }
        return result ?? { content: [], isError: false };
    }
    /** Graceful shutdown */
    async disconnect() {
        if (!this._connected)
            return;
        this._connected = false;
        // Reject all pending requests
        for (const [id, pending] of this._pendingRequests) {
            clearTimeout(pending.timer);
            pending.reject(new Error(`[mcp:${this.serverName}] Client disconnecting`));
            this._pendingRequests.delete(id);
        }
        if (this._process) {
            const proc = this._process;
            this._process = null;
            this._readline?.close();
            this._readline = null;
            // Try graceful shutdown, then force kill after 5s
            if (!proc.killed) {
                proc.kill('SIGTERM');
                const killTimer = setTimeout(() => {
                    if (!proc.killed)
                        proc.kill('SIGKILL');
                }, 5000);
                proc.once('exit', () => clearTimeout(killTimer));
            }
        }
        this._config = null;
        this._log('Disconnected');
    }
    // ── Stdio transport ──────────────────────────────────────────────
    async _connectStdio(config) {
        const { command, args = [], env, cwd } = config;
        // Build environment: inherit process.env, overlay expanded config env
        const childEnv = { ...process.env };
        if (env) {
            const expanded = expandEnvRecord(env);
            Object.assign(childEnv, expanded);
        }
        return new Promise((resolve, reject) => {
            const timeout = setTimeout(() => {
                reject(new Error(`[mcp:${this.serverName}] Connection timeout (${CONNECTION_TIMEOUT_MS}ms)`));
                // Kill process directly — this.disconnect() guards on this._connected which is still false
                if (this._process) {
                    this._process.kill('SIGTERM');
                    this._process = null;
                }
                if (this._readline) {
                    this._readline.close();
                    this._readline = null;
                }
            }, CONNECTION_TIMEOUT_MS);
            try {
                // On Windows we need shell: true so commands like `npx` resolve.
                // But passing args separately with shell: true triggers DEP0190, so
                // we join everything into a single shell string with no args array.
                const useShell = process.platform === 'win32';
                let spawnCmd;
                let spawnArgs;
                if (useShell) {
                    // Quote the command if it contains spaces, then append quoted args
                    const quotedCmd = command.includes(' ') ? `"${command}"` : command;
                    const quotedArgs = args.map(a => a.includes(' ') ? `"${a}"` : a);
                    spawnCmd = [quotedCmd, ...quotedArgs].join(' ');
                    spawnArgs = [];
                }
                else {
                    spawnCmd = command;
                    spawnArgs = args;
                }
                this._process = spawn(spawnCmd, spawnArgs, {
                    stdio: ['pipe', 'pipe', 'pipe'],
                    env: childEnv,
                    cwd: cwd || undefined,
                    windowsHide: true,
                    shell: useShell,
                });
            }
            catch (err) {
                clearTimeout(timeout);
                reject(new Error(`[mcp:${this.serverName}] Failed to spawn process: ${err}`));
                return;
            }
            const proc = this._process;
            // Handle spawn error
            proc.on('error', (err) => {
                clearTimeout(timeout);
                this._connected = false;
                this._log(`Process error: ${err.message}`);
                reject(new Error(`[mcp:${this.serverName}] Process error: ${err.message}`));
            });
            // Handle unexpected exit
            proc.on('exit', (code, signal) => {
                this._connected = false;
                this._process = null;
                this._readline?.close();
                this._readline = null;
                // Reject all pending requests
                for (const [id, pending] of this._pendingRequests) {
                    clearTimeout(pending.timer);
                    pending.reject(new Error(`[mcp:${this.serverName}] Process exited (code=${code}, signal=${signal})`));
                    this._pendingRequests.delete(id);
                }
                this._log(`Process exited (code=${code}, signal=${signal})`);
            });
            // Log stderr
            proc.stderr?.on('data', (data) => {
                this._log(`stderr: ${data.toString().trimEnd()}`);
            });
            // Set up readline for newline-delimited JSON-RPC on stdout
            this._readline = createInterface({ input: proc.stdout });
            this._readline.on('line', (line) => {
                this._handleStdioLine(line);
            });
            // Perform initialize handshake
            this._performInitialize()
                .then(() => {
                clearTimeout(timeout);
                this._connected = true;
                this._log('Connected (stdio)');
                resolve();
            })
                .catch((err) => {
                clearTimeout(timeout);
                this.disconnect();
                reject(err);
            });
        });
    }
    _handleStdioLine(line) {
        const trimmed = line.trim();
        if (!trimmed)
            return;
        let message;
        try {
            message = JSON.parse(trimmed);
        }
        catch {
            this._log(`Invalid JSON from server: ${trimmed.slice(0, 200)}`);
            return;
        }
        // Match response to pending request
        if (typeof message.id === 'number') {
            const pending = this._pendingRequests.get(message.id);
            if (pending) {
                clearTimeout(pending.timer);
                this._pendingRequests.delete(message.id);
                pending.resolve(message);
            }
        }
        // Notifications from server (no id) are logged but ignored
    }
    _sendStdioRequest(request) {
        return new Promise((resolve, reject) => {
            if (!this._process?.stdin?.writable) {
                reject(new Error(`[mcp:${this.serverName}] stdin not writable`));
                return;
            }
            const timer = setTimeout(() => {
                this._pendingRequests.delete(request.id);
                reject(new Error(`[mcp:${this.serverName}] Request timeout (${REQUEST_TIMEOUT_MS}ms) for ${request.method}`));
            }, REQUEST_TIMEOUT_MS);
            this._pendingRequests.set(request.id, { resolve, reject, timer });
            const data = JSON.stringify(request) + '\n';
            this._process.stdin.write(data, (err) => {
                if (err) {
                    clearTimeout(timer);
                    this._pendingRequests.delete(request.id);
                    reject(new Error(`[mcp:${this.serverName}] Failed to write to stdin: ${err.message}`));
                }
            });
        });
    }
    _sendStdioNotification(notification) {
        if (!this._process?.stdin?.writable)
            return;
        const data = JSON.stringify(notification) + '\n';
        this._process.stdin.write(data);
    }
    // ── HTTP transport ───────────────────────────────────────────────
    async _connectHttp(config) {
        // Validate URL
        try {
            new URL(config.url);
        }
        catch {
            throw new Error(`[mcp:${this.serverName}] Invalid URL: ${config.url}`);
        }
        // Perform initialize handshake over HTTP
        await this._performInitialize();
        this._connected = true;
        this._log('Connected (HTTP)');
    }
    async _sendHttpRequest(request) {
        const url = this._config.url;
        const headers = {
            'Content-Type': 'application/json',
            // Streamable HTTP transport spec: client MUST advertise both response shapes.
            Accept: 'application/json, text/event-stream',
            ...this._config.headers,
        };
        const controller = new AbortController();
        const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
        try {
            const response = await fetch(url, {
                method: 'POST',
                headers,
                body: JSON.stringify(request),
                signal: controller.signal,
            });
            if (!response.ok) {
                const body = await response.text().catch(() => '');
                throw new Error(`HTTP ${response.status}: ${body.slice(0, 500)}`);
            }
            const contentType = response.headers.get('content-type') || '';
            if (contentType.includes('text/event-stream')) {
                // SSE response — parse data: lines for JSON-RPC result
                const text = await response.text();
                let lastData;
                for (const line of text.split('\n')) {
                    if (line.startsWith('data:')) {
                        lastData = line.slice(5).trim();
                    }
                }
                if (lastData) {
                    try {
                        return JSON.parse(lastData);
                    }
                    catch {
                        return { jsonrpc: '2.0', id: request.id, result: { content: [{ type: 'text', text: lastData }] } };
                    }
                }
                return { jsonrpc: '2.0', id: request.id, error: { code: -1, message: 'Empty SSE response' } };
            }
            return await response.json();
        }
        catch (err) {
            if (err instanceof Error && err.name === 'AbortError') {
                throw new Error(`[mcp:${this.serverName}] Request timeout (${REQUEST_TIMEOUT_MS}ms) for ${request.method}`);
            }
            throw new Error(`[mcp:${this.serverName}] HTTP request failed: ${err}`);
        }
        finally {
            clearTimeout(timer);
        }
    }
    async _sendHttpNotification(notification) {
        const url = this._config.url;
        const headers = {
            'Content-Type': 'application/json',
            Accept: 'application/json, text/event-stream',
            ...this._config.headers,
        };
        // Fire and forget — notifications don't expect responses
        try {
            await fetch(url, {
                method: 'POST',
                headers,
                body: JSON.stringify(notification),
            });
        }
        catch {
            // Ignore errors for notifications
        }
    }
    // ── Transport-agnostic helpers ───────────────────────────────────
    _isStdio() {
        return !!this._config?.command;
    }
    async _sendRequest(method, params) {
        const request = {
            jsonrpc: '2.0',
            id: this._nextId++,
            method,
            params,
        };
        if (this._isStdio()) {
            return this._sendStdioRequest(request);
        }
        else {
            return this._sendHttpRequest(request);
        }
    }
    _sendNotification(method, params) {
        const notification = {
            jsonrpc: '2.0',
            method,
            ...(params ? { params } : {}),
        };
        if (this._isStdio()) {
            this._sendStdioNotification(notification);
        }
        else {
            // HTTP notifications are async but we don't await in most call-sites
            return this._sendHttpNotification(notification);
        }
    }
    /** Perform the MCP initialize handshake */
    async _performInitialize() {
        const response = await this._sendRequest('initialize', {
            protocolVersion: PROTOCOL_VERSION,
            capabilities: {},
            clientInfo: CLIENT_INFO,
        });
        if (response.error) {
            throw new Error(`[mcp:${this.serverName}] Initialize failed: ${response.error.message}`);
        }
        // Send initialized notification
        await this._sendNotification('notifications/initialized', {});
    }
    _assertConnected() {
        if (!this._connected) {
            throw new Error(`[mcp:${this.serverName}] Not connected`);
        }
    }
    _log(message) {
        console.error(`[mcp:${this.serverName}] ${message}`);
    }
}
//# sourceMappingURL=mcp-client.js.map