/**
 * MCP stdio transport client.
 *
 * Implements JSON-RPC 2.0 over stdin/stdout to communicate
 * with MCP servers. Clean-room implementation based on public spec.
 */
import { spawn } from 'node:child_process';
import { EventEmitter } from 'node:events';
const PROTOCOL_VERSION = '2024-11-05';
const CONNECT_TIMEOUT_MS = 15_000;
const REQUEST_TIMEOUT_MS = 30_000;
export class MCPClient extends EventEmitter {
    serverName;
    config;
    proc = null;
    nextId = 1;
    pending = new Map();
    readBuffer = '';
    _connected = false;
    _capabilities;
    _serverInfo;
    constructor(serverName, config) {
        super();
        this.serverName = serverName;
        this.config = config;
    }
    get connected() {
        return this._connected;
    }
    get capabilities() {
        return this._capabilities;
    }
    get serverInfo() {
        return this._serverInfo;
    }
    /** Spawn the MCP server process and complete the initialize handshake. */
    async connect() {
        if (this._connected)
            throw new Error(`Already connected to ${this.serverName}`);
        const env = { ...process.env, ...this.config.env };
        this.proc = spawn(this.config.command, this.config.args ?? [], {
            stdio: ['pipe', 'pipe', 'pipe'],
            env,
            cwd: this.config.cwd ?? process.cwd(),
            detached: false,
        });
        // Capture spawn errors immediately — ENOENT etc. fire on next tick
        const spawnError = new Promise((_, reject) => {
            this.proc.on('error', (err) => {
                this._connected = false;
                this.rejectAll(err);
                reject(err);
            });
        });
        // Wire up data handlers
        this.proc.stdout.on('data', (chunk) => this.onData(chunk));
        this.proc.stderr.on('data', (chunk) => {
            this.emit('stderr', chunk.toString());
        });
        this.proc.on('exit', (code) => {
            this._connected = false;
            this.rejectAll(new Error(`MCP server "${this.serverName}" exited with code ${code}`));
            this.emit('exit', code);
        });
        // Wait for process to be ready, or fail fast on spawn error
        await Promise.race([
            new Promise((r) => setTimeout(r, 100)),
            spawnError,
        ]);
        // If process died during startup, bail
        if (this.proc.exitCode !== null || this.proc.killed) {
            throw new Error(`MCP server "${this.serverName}" failed to start`);
        }
        // Send initialize request
        const result = (await this.request('initialize', {
            protocolVersion: PROTOCOL_VERSION,
            capabilities: {
                roots: { listChanged: false },
            },
            clientInfo: {
                name: 'owlcoda',
                version: '0.7.0',
            },
        }, CONNECT_TIMEOUT_MS));
        this._capabilities = result.capabilities;
        this._serverInfo = result.serverInfo;
        this._connected = true;
        // Send initialized notification
        this.notify('notifications/initialized', {});
        return result;
    }
    /** Disconnect the MCP server. */
    async disconnect() {
        if (!this._connected || !this.proc)
            return;
        this._connected = false;
        this.rejectAll(new Error('Disconnecting'));
        // Try graceful exit
        try {
            this.proc.stdin.end();
            this.proc.kill('SIGTERM');
        }
        catch {
            // Ignore — process may already be dead
        }
        // Wait briefly for clean exit
        await new Promise((resolve) => {
            const timer = setTimeout(() => {
                try {
                    this.proc?.kill('SIGKILL');
                }
                catch { /* ignore */ }
                resolve();
            }, 2000);
            this.proc.on('exit', () => {
                clearTimeout(timer);
                resolve();
            });
        });
        this.proc = null;
    }
    // ─── MCP methods ────────────────────────────────────────────────
    async listTools() {
        const res = (await this.request('tools/list', {}));
        return res.tools ?? [];
    }
    async callTool(name, args = {}) {
        return (await this.request('tools/call', { name, arguments: args }));
    }
    async listResources() {
        const res = (await this.request('resources/list', {}));
        return res.resources ?? [];
    }
    async readResource(uri) {
        const res = (await this.request('resources/read', { uri }));
        return res.contents ?? [];
    }
    // ─── JSON-RPC transport ─────────────────────────────────────────
    request(method, params, timeout = REQUEST_TIMEOUT_MS) {
        return new Promise((resolve, reject) => {
            if (!this.proc?.stdin?.writable) {
                return reject(new Error(`Cannot send request — server "${this.serverName}" not running`));
            }
            const id = this.nextId++;
            const msg = { jsonrpc: '2.0', id, method, params };
            const timer = setTimeout(() => {
                this.pending.delete(id);
                reject(new Error(`MCP request "${method}" timed out after ${timeout}ms`));
            }, timeout);
            this.pending.set(id, { resolve, reject, timer });
            const json = JSON.stringify(msg);
            this.proc.stdin.write(json + '\n');
        });
    }
    notify(method, params) {
        if (!this.proc?.stdin?.writable)
            return;
        const msg = { jsonrpc: '2.0', method, params };
        this.proc.stdin.write(JSON.stringify(msg) + '\n');
    }
    onData(chunk) {
        this.readBuffer += chunk.toString();
        // Process complete lines (newline-delimited JSON-RPC)
        let nlIdx;
        while ((nlIdx = this.readBuffer.indexOf('\n')) !== -1) {
            const line = this.readBuffer.slice(0, nlIdx).trim();
            this.readBuffer = this.readBuffer.slice(nlIdx + 1);
            if (!line)
                continue;
            try {
                const msg = JSON.parse(line);
                // If it has an id, it's a response to one of our requests
                if ('id' in msg && msg.id !== undefined) {
                    const pending = this.pending.get(msg.id);
                    if (pending) {
                        this.pending.delete(msg.id);
                        clearTimeout(pending.timer);
                        const resp = msg;
                        if (resp.error) {
                            pending.reject(new Error(`MCP error ${resp.error.code}: ${resp.error.message}`));
                        }
                        else {
                            pending.resolve(resp.result);
                        }
                    }
                }
                else if ('method' in msg) {
                    // Server notification
                    this.emit('notification', msg);
                }
            }
            catch {
                // Ignore malformed lines (e.g. debug output from server)
            }
        }
    }
    rejectAll(err) {
        for (const [id, pending] of this.pending) {
            clearTimeout(pending.timer);
            pending.reject(err);
        }
        this.pending.clear();
    }
}
//# sourceMappingURL=client.js.map