import { spawn } from 'node:child_process';
import { z } from 'zod';
import { truncateOutput } from '../tools/types.js';
function contentToText(result) {
    const parts = [];
    if (Array.isArray(result?.content)) {
        for (const item of result.content) {
            if (item?.type === 'text' && typeof item.text === 'string')
                parts.push(item.text);
            else
                parts.push(JSON.stringify(item));
        }
    }
    const text = parts.join('\n') || JSON.stringify(result);
    return result?.isError ? `error: ${text}` : text;
}
/**
 * Minimal MCP client over stdio: newline-delimited JSON-RPC 2.0.
 * Supports the initialize handshake, tools/list, and tools/call.
 */
export class McpClient {
    serverName;
    child;
    nextId = 1;
    pending = new Map();
    buffer = '';
    constructor(serverName, config) {
        this.serverName = serverName;
        this.child = spawn(config.command, config.args ?? [], {
            env: { ...process.env, ...config.env },
            stdio: ['pipe', 'pipe', 'pipe'],
        });
        this.child.stdout.on('data', (chunk) => this.onData(chunk.toString()));
        this.child.stderr.on('data', () => {
            /* server logs; ignore */
        });
        this.child.on('close', () => {
            for (const [, p] of this.pending)
                p.reject(new Error(`MCP server "${serverName}" exited`));
            this.pending.clear();
        });
    }
    onData(text) {
        this.buffer += text;
        const lines = this.buffer.split('\n');
        this.buffer = lines.pop() ?? '';
        for (const line of lines) {
            const trimmed = line.trim();
            if (!trimmed)
                continue;
            let msg;
            try {
                msg = JSON.parse(trimmed);
            }
            catch {
                continue;
            }
            if (typeof msg.id === 'number' && this.pending.has(msg.id)) {
                const p = this.pending.get(msg.id);
                this.pending.delete(msg.id);
                if (msg.error)
                    p.reject(new Error(msg.error.message ?? 'MCP error'));
                else
                    p.resolve(msg.result);
            }
            // Requests/notifications from the server are ignored in this minimal client.
        }
    }
    request(method, params, timeoutMs = 30_000) {
        const id = this.nextId++;
        const payload = JSON.stringify({ jsonrpc: '2.0', id, method, params });
        return new Promise((resolve, reject) => {
            const timer = setTimeout(() => {
                this.pending.delete(id);
                reject(new Error(`MCP request ${method} timed out after ${timeoutMs}ms`));
            }, timeoutMs);
            this.pending.set(id, {
                resolve: (v) => {
                    clearTimeout(timer);
                    resolve(v);
                },
                reject: (e) => {
                    clearTimeout(timer);
                    reject(e);
                },
            });
            this.child.stdin.write(payload + '\n');
        });
    }
    notify(method, params) {
        this.child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n');
    }
    async initialize() {
        await this.request('initialize', {
            protocolVersion: '2025-06-18',
            capabilities: {},
            clientInfo: { name: 'cheapcode', version: '0.2.0' },
        });
        this.notify('notifications/initialized');
    }
    async listTools() {
        const result = await this.request('tools/list', {});
        return Array.isArray(result?.tools) ? result.tools : [];
    }
    async callTool(name, args) {
        const result = await this.request('tools/call', { name, arguments: args }, 120_000);
        return contentToText(result);
    }
    close() {
        try {
            this.child.kill();
        }
        catch {
            /* already dead */
        }
    }
}
/**
 * MCP over streamable HTTP: JSON-RPC requests POSTed to the endpoint.
 * Handles both plain JSON and SSE-formatted responses, and carries the
 * Mcp-Session-Id header once the server assigns one.
 */
export class HttpMcpClient {
    serverName;
    config;
    nextId = 1;
    sessionId = null;
    constructor(serverName, config) {
        this.serverName = serverName;
        this.config = config;
    }
    headers() {
        return {
            'Content-Type': 'application/json',
            Accept: 'application/json, text/event-stream',
            ...(this.sessionId ? { 'Mcp-Session-Id': this.sessionId } : {}),
            ...(this.config.headers ?? {}),
        };
    }
    async post(payload, expectResponse) {
        const res = await fetch(this.config.url, {
            method: 'POST',
            headers: this.headers(),
            body: JSON.stringify(payload),
            signal: AbortSignal.timeout(120_000),
        });
        const sid = res.headers.get('mcp-session-id');
        if (sid)
            this.sessionId = sid;
        if (!res.ok) {
            throw new Error(`MCP HTTP ${res.status} from ${this.serverName}`);
        }
        if (!expectResponse)
            return null;
        const contentType = res.headers.get('content-type') ?? '';
        if (contentType.includes('text/event-stream')) {
            // Read the SSE stream until the JSON-RPC response with our id arrives.
            const id = payload.id;
            const text = await res.text();
            for (const line of text.split('\n')) {
                if (!line.startsWith('data:'))
                    continue;
                try {
                    const msg = JSON.parse(line.slice(5).trim());
                    if (msg.id === id) {
                        if (msg.error)
                            throw new Error(msg.error.message ?? 'MCP error');
                        return msg.result;
                    }
                }
                catch (err) {
                    if (err instanceof SyntaxError)
                        continue;
                    throw err;
                }
            }
            throw new Error(`no response for request ${id} in SSE stream from ${this.serverName}`);
        }
        const msg = await res.json();
        if (msg.error)
            throw new Error(msg.error.message ?? 'MCP error');
        return msg.result;
    }
    request(method, params) {
        return this.post({ jsonrpc: '2.0', id: this.nextId++, method, params }, true);
    }
    async initialize() {
        await this.request('initialize', {
            protocolVersion: '2025-06-18',
            capabilities: {},
            clientInfo: { name: 'cheapcode', version: '0.3.0' },
        });
        await this.post({ jsonrpc: '2.0', method: 'notifications/initialized' }, false);
    }
    async listTools() {
        const result = await this.request('tools/list', {});
        return Array.isArray(result?.tools) ? result.tools : [];
    }
    async callTool(name, args) {
        const result = await this.request('tools/call', { name, arguments: args });
        return contentToText(result);
    }
    close() {
        /* stateless HTTP; nothing to clean up */
    }
}
function sanitize(name) {
    return name.replace(/[^a-zA-Z0-9_-]/g, '_');
}
/**
 * Connect all configured MCP servers and wrap their tools as CheapCode tools
 * (named mcp__server__tool). Connection failures are warnings, not fatal.
 */
export async function connectMcpServers(servers, onWarn) {
    const tools = [];
    const clients = [];
    await Promise.all(Object.entries(servers).map(async ([name, config]) => {
        try {
            let client;
            if (config.command) {
                client = new McpClient(name, config);
            }
            else if (config.url) {
                client = new HttpMcpClient(name, config);
            }
            else {
                onWarn(`MCP server "${name}" has neither "command" nor "url"; skipped`);
                return;
            }
            await client.initialize();
            const defs = await client.listTools();
            clients.push(client);
            for (const def of defs) {
                tools.push({
                    name: `mcp__${sanitize(name)}__${sanitize(def.name)}`,
                    description: `[MCP: ${name}] ${def.description ?? def.name}`,
                    schema: z.record(z.any()).or(z.object({}).passthrough()),
                    jsonSchema: def.inputSchema ?? { type: 'object', properties: {} },
                    readOnly: def.annotations?.readOnlyHint === true,
                    summarize: (input) => JSON.stringify(input).slice(0, 80),
                    run: async (input) => truncateOutput(await client.callTool(def.name, input ?? {})),
                });
            }
        }
        catch (err) {
            onWarn(`MCP server "${name}" failed to connect: ${err.message}`);
        }
    }));
    return { tools, clients };
}
