/**
 * MCP server connection — connect, listTools, callTool, close.
 */
import { extractWWWAuthenticateParams, UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { StreamableHTTPClientTransport, StreamableHTTPError } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { randomUUID } from 'crypto';
import { mkdir, writeFile } from 'fs/promises';
import { basename, extname, join } from 'path';
import { getAgentTempDir } from '../../config.js';
import { log } from '../../shared/logging/agent-log.js';
import { getSecret } from '../connectors/secret-store.js';
import { getHostedMcpOAuthProviderId } from './hosted-oauth-id.js';
import { createHostedMcpRuntimeAuthProvider } from './hosted-oauth-provider.js';
import { resolveHostedMcpOAuthTimeoutMs, supportsHostedMcpOAuth } from './oauth-discovery.js';
import { withHostedMcpProxyFetchOptions } from './proxy-fetch.js';
import { isHostedServerConfig } from './types.js';
const DEFAULT_STARTUP_TIMEOUT_MS = 30_000;
const DEFAULT_TOOL_CALL_TIMEOUT_MS = 600_000;
const DEFAULT_RESET_TIMEOUT_ON_PROGRESS = true;
const MCP_OUTPUT_DIR_NAME = 'mcp';
function isRecord(value) {
    return typeof value === 'object' && value !== null;
}
function getBlockType(block) {
    if (!isRecord(block))
        return undefined;
    return typeof block.type === 'string' ? block.type : undefined;
}
function isTextBlock(block) {
    return (getBlockType(block) === 'text' &&
        isRecord(block) &&
        (block.text === undefined || typeof block.text === 'string'));
}
function isEmbeddedResource(value) {
    if (!isRecord(value))
        return false;
    return ((value.uri === undefined || typeof value.uri === 'string') &&
        (value.name === undefined || typeof value.name === 'string') &&
        (value.description === undefined || typeof value.description === 'string') &&
        (value.mimeType === undefined || typeof value.mimeType === 'string') &&
        (value.text === undefined || typeof value.text === 'string') &&
        (value.blob === undefined || typeof value.blob === 'string'));
}
function isResourceBlock(block) {
    if (getBlockType(block) !== 'resource' || !isRecord(block))
        return false;
    return block.resource === undefined || isEmbeddedResource(block.resource);
}
function isResourceLinkBlock(block) {
    if (getBlockType(block) !== 'resource_link' || !isRecord(block))
        return false;
    return ((block.uri === undefined || typeof block.uri === 'string') &&
        (block.name === undefined || typeof block.name === 'string') &&
        (block.description === undefined || typeof block.description === 'string') &&
        (block.mimeType === undefined || typeof block.mimeType === 'string'));
}
function isBinaryBlock(block) {
    const type = getBlockType(block);
    if ((type !== 'image' && type !== 'audio') || !isRecord(block))
        return false;
    return ((block.data === undefined || typeof block.data === 'string') &&
        (block.mimeType === undefined || typeof block.mimeType === 'string'));
}
function sanitizeFileStem(value) {
    const normalized = value
        .trim()
        .replace(/[^a-zA-Z0-9._-]+/g, '-')
        .replace(/-+/g, '-')
        .replace(/^[-_.]+|[-_.]+$/g, '');
    return normalized.slice(0, 48) || 'mcp-output';
}
function tryParseUriPath(uri) {
    if (!uri)
        return undefined;
    try {
        return new URL(uri).pathname || undefined;
    }
    catch {
        return uri;
    }
}
function inferExtensionFromMimeType(mimeType) {
    switch (mimeType?.toLowerCase()) {
        case 'text/plain':
            return 'txt';
        case 'text/csv':
            return 'csv';
        case 'text/markdown':
            return 'md';
        case 'application/json':
            return 'json';
        case 'application/pdf':
            return 'pdf';
        case 'application/xml':
        case 'text/xml':
            return 'xml';
        case 'image/png':
            return 'png';
        case 'image/jpeg':
            return 'jpg';
        case 'image/gif':
            return 'gif';
        case 'image/webp':
            return 'webp';
        case 'audio/wav':
            return 'wav';
        case 'audio/mpeg':
            return 'mp3';
        case 'audio/mp4':
            return 'm4a';
        default:
            return undefined;
    }
}
function inferOutputExtension(sourceUri, mimeType, fallback) {
    const uriPath = tryParseUriPath(sourceUri);
    const uriExtension = uriPath ? extname(uriPath).replace(/^\./, '') : '';
    if (uriExtension)
        return uriExtension.slice(0, 10);
    return inferExtensionFromMimeType(mimeType) ?? fallback;
}
function inferOutputStem(preferredName, sourceUri, fallback) {
    const uriPath = tryParseUriPath(sourceUri);
    const uriBaseName = uriPath ? basename(uriPath) : undefined;
    const selected = preferredName || uriBaseName || fallback;
    const withoutExtension = selected.replace(/\.[^.]+$/, '');
    return sanitizeFileStem(withoutExtension || fallback);
}
async function persistMcpOutput(serverName, toolName, bytes, options) {
    const outputDir = join(getAgentTempDir(), MCP_OUTPUT_DIR_NAME);
    await mkdir(outputDir, { recursive: true });
    const extension = inferOutputExtension(options.sourceUri, options.mimeType, options.fallbackExtension);
    const fileStem = inferOutputStem(options.preferredName, options.sourceUri, options.fallbackName);
    const prefix = `${sanitizeFileStem(serverName)}-${sanitizeFileStem(toolName)}-${fileStem}`;
    const outputPath = join(outputDir, `${prefix}-${randomUUID().slice(0, 8)}.${extension}`);
    await writeFile(outputPath, bytes);
    const details = [];
    if (options.mimeType)
        details.push(options.mimeType);
    if (options.sourceUri)
        details.push(`uri=${options.sourceUri}`);
    const detailText = details.length > 0 ? ` (${details.join(', ')})` : '';
    return `${options.label} saved to ${outputPath}${detailText}. Use the read tool if you need to inspect it.`;
}
async function renderEmbeddedResource(serverName, toolName, resource) {
    if (!resource)
        return '[resource]';
    if (typeof resource.text === 'string') {
        return persistMcpOutput(serverName, toolName, Buffer.from(resource.text, 'utf-8'), {
            label: `Embedded resource from MCP server '${serverName}'`,
            sourceUri: resource.uri,
            preferredName: resource.name,
            mimeType: resource.mimeType,
            fallbackExtension: 'txt',
            fallbackName: 'resource'
        });
    }
    if (typeof resource.blob === 'string') {
        return persistMcpOutput(serverName, toolName, Buffer.from(resource.blob, 'base64'), {
            label: `Embedded resource from MCP server '${serverName}'`,
            sourceUri: resource.uri,
            preferredName: resource.name,
            mimeType: resource.mimeType,
            fallbackExtension: 'bin',
            fallbackName: 'resource'
        });
    }
    const target = resource.uri ?? resource.name ?? 'unknown resource';
    return `[Embedded resource from MCP server '${serverName}': ${target}]`;
}
async function renderBinaryBlock(serverName, toolName, block) {
    if (typeof block.data !== 'string')
        return `[${block.type}]`;
    return persistMcpOutput(serverName, toolName, Buffer.from(block.data, 'base64'), {
        label: `MCP ${block.type} content from server '${serverName}'`,
        mimeType: block.mimeType,
        fallbackExtension: block.type === 'image' ? 'png' : 'bin',
        fallbackName: block.type
    });
}
function renderResourceLink(block) {
    const name = block.name ? `[Resource link: ${block.name}] ` : '[Resource link] ';
    const target = block.uri ?? '(missing uri)';
    const description = block.description ? ` (${block.description})` : '';
    return `${name}${target}${description}`;
}
async function renderContentBlock(serverName, toolName, block) {
    if (isTextBlock(block))
        return block.text ?? '';
    if (isResourceBlock(block))
        return renderEmbeddedResource(serverName, toolName, block.resource);
    if (isResourceLinkBlock(block))
        return renderResourceLink(block);
    if (isBinaryBlock(block))
        return renderBinaryBlock(serverName, toolName, block);
    const type = getBlockType(block);
    return `[${type ?? 'unknown'}]`;
}
async function normalizeToolResultText(serverName, toolName, result) {
    const content = isRecord(result) ? result.content : undefined;
    const structuredContent = isRecord(result) ? result.structuredContent : undefined;
    const blocks = Array.isArray(content) ? content : [];
    if (blocks.length > 0) {
        const renderedBlocks = await Promise.all(blocks.map((block) => renderContentBlock(serverName, toolName, block)));
        return renderedBlocks.filter((block) => block.length > 0).join('\n');
    }
    if (structuredContent !== undefined) {
        return JSON.stringify(structuredContent, null, 2);
    }
    return '';
}
export class McpConnectionError extends Error {
    failureKind;
    constructor(failureKind, message) {
        super(message);
        this.failureKind = failureKind;
        this.name = 'McpConnectionError';
    }
}
export function resolveStartupTimeoutMs(config) {
    return config.startupTimeoutMs ?? config.timeout ?? DEFAULT_STARTUP_TIMEOUT_MS;
}
export function buildRequestPolicy(config) {
    return {
        timeoutMs: config.toolCallTimeoutMs ?? DEFAULT_TOOL_CALL_TIMEOUT_MS,
        resetTimeoutOnProgress: config.resetToolCallTimeoutOnProgress ?? DEFAULT_RESET_TIMEOUT_ON_PROGRESS,
        ...(config.maxToolCallTotalTimeoutMs !== undefined
            ? { maxTotalTimeoutMs: config.maxToolCallTotalTimeoutMs }
            : {})
    };
}
async function buildHostedHeaders(name, config) {
    const headers = { ...(config.headers ?? {}) };
    if (config.connection) {
        const secrets = getSecret(config.connection);
        if (!secrets) {
            throw new Error(`MCP server '${name}' references connection '${config.connection}' but no credentials found in keychain. Run /connect to set it up.`);
        }
        Object.assign(headers, secrets);
    }
    return headers;
}
function hasAuthorizationHeader(headers) {
    return Object.keys(headers).some((key) => key.toLowerCase() === 'authorization');
}
function redactUrlForLog(input) {
    const raw = input instanceof Request ? input.url : String(input);
    try {
        const url = new URL(raw);
        const path = `${url.origin}${url.pathname}`;
        return url.search ? `${path}?<redacted>` : path;
    }
    catch {
        return raw.split('?')[0] ?? raw;
    }
}
function readRequestMethod(input, init) {
    return (init?.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase();
}
function readRequestHeaders(input, init) {
    return new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
}
function inferMcpHttpPhase(method, init) {
    if (method === 'GET') {
        return 'sse_open';
    }
    if (method === 'DELETE') {
        return 'session_terminate';
    }
    if (method !== 'POST') {
        return method.toLowerCase();
    }
    const body = init?.body;
    if (typeof body !== 'string') {
        return 'post';
    }
    try {
        const parsed = JSON.parse(body);
        const first = Array.isArray(parsed) ? parsed[0] : parsed;
        if (isRecord(first) && typeof first.method === 'string') {
            return first.method;
        }
    }
    catch {
        // Ignore unparsable request bodies; the SDK owns the wire format.
    }
    return 'post';
}
function summarizeRequestHeaders(headers) {
    const headerNames = [...headers.keys()].sort();
    const accept = headers.get('accept');
    const contentType = headers.get('content-type');
    const protocolVersion = headers.get('mcp-protocol-version');
    return JSON.stringify({
        headerNames,
        accept,
        contentType,
        hasAuthorization: headers.has('authorization'),
        hasMcpSessionId: headers.has('mcp-session-id'),
        ...(protocolVersion ? { mcpProtocolVersion: protocolVersion } : {})
    });
}
function summarizeResponseHeaders(response) {
    const headerNames = [...response.headers.keys()].sort();
    const contentType = response.headers.get('content-type');
    const location = response.headers.get('location');
    const mcpSessionId = response.headers.get('mcp-session-id');
    const wwwAuthenticate = response.headers.get('www-authenticate');
    const authParams = extractWWWAuthenticateParams(response);
    return JSON.stringify({
        headerNames,
        contentType,
        hasMcpSessionId: !!mcpSessionId,
        ...(location ? { location: redactUrlForLog(location) } : {}),
        ...(wwwAuthenticate
            ? {
                wwwAuthenticate: {
                    hasHeader: true,
                    ...(authParams.error ? { error: authParams.error } : {}),
                    ...(authParams.scope ? { scope: authParams.scope } : {}),
                    ...(authParams.resourceMetadataUrl
                        ? { resourceMetadataUrl: redactUrlForLog(authParams.resourceMetadataUrl.toString()) }
                        : {})
                }
            }
            : {})
    });
}
function buildMcpHttpRequestLogContext(input, init) {
    const method = readRequestMethod(input, init);
    const headers = readRequestHeaders(input, init);
    const phase = inferMcpHttpPhase(method, init);
    return `phase='${phase}' method='${method}' url='${redactUrlForLog(input)}' requestHeaders=${summarizeRequestHeaders(headers)}`;
}
function summarizeErrorForLog(error, depth = 0) {
    if (!(error instanceof Error)) {
        return String(error);
    }
    const details = {
        name: error.name,
        message: error.message
    };
    const record = error;
    for (const key of ['code', 'errno', 'syscall', 'hostname', 'host', 'port', 'address']) {
        const value = record[key];
        if (value !== undefined) {
            details[key] = value;
        }
    }
    if (record.cause !== undefined && depth < 3) {
        details.cause = summarizeErrorForLog(record.cause, depth + 1);
    }
    return details;
}
async function readResponseSnippet(response) {
    try {
        const text = await response.clone().text();
        return text.replace(/\s+/g, ' ').trim().slice(0, 500);
    }
    catch {
        return '<unavailable>';
    }
}
export function createHostedMcpLoggingFetch(serverName, fetchImpl = fetch) {
    return async (input, init) => {
        const startedAt = Date.now();
        const requestContext = buildMcpHttpRequestLogContext(input, init);
        let response;
        try {
            response = await fetchImpl(input, withHostedMcpProxyFetchOptions(input, init));
        }
        catch (err) {
            log.warn('mcp', `Hosted MCP fetch failed: server='${serverName}' ${requestContext} durationMs=${Date.now() - startedAt} error=${JSON.stringify(summarizeErrorForLog(err))}`);
            throw err;
        }
        if (response.ok) {
            return response;
        }
        const bodySnippet = await readResponseSnippet(response);
        log.warn('mcp', `Hosted MCP HTTP failure: server='${serverName}' ${requestContext} durationMs=${Date.now() - startedAt} status=${response.status} statusText='${response.statusText}' responseHeaders=${summarizeResponseHeaders(response)} responseBody='${bodySnippet}'`);
        return response;
    };
}
async function connectHostedServer(name, config, authStorage, mcpOAuthStore, retriedAfterOAuthRefresh = false) {
    const startupTimeoutMs = resolveStartupTimeoutMs(config);
    const requestPolicy = buildRequestPolicy(config);
    const headers = await buildHostedHeaders(name, config);
    const authProvider = authStorage &&
        mcpOAuthStore &&
        !hasAuthorizationHeader(headers) &&
        authStorage.get(getHostedMcpOAuthProviderId(name, config))?.type === 'oauth'
        ? createHostedMcpRuntimeAuthProvider(name, config, authStorage, mcpOAuthStore)
        : undefined;
    const transport = new StreamableHTTPClientTransport(new URL(config.url), {
        requestInit: { headers },
        fetch: createHostedMcpLoggingFetch(name),
        authProvider
    });
    const client = new Client({ name: 'shortcut-agent', version: '1.0.0' }, { capabilities: {} });
    let timer;
    const connectPromise = client.connect(transport);
    const timeoutPromise = new Promise((_, reject) => {
        timer = setTimeout(() => reject(new Error(`did not respond within ${startupTimeoutMs / 1000}s`)), startupTimeoutMs);
        if (typeof timer === 'object' && 'unref' in timer)
            timer.unref();
    });
    try {
        await Promise.race([connectPromise, timeoutPromise]);
        if (timer)
            clearTimeout(timer);
    }
    catch (err) {
        try {
            await client.close();
        }
        catch {
            // Ignore close errors during cleanup.
        }
        if (err instanceof UnauthorizedError ||
            (err instanceof StreamableHTTPError && err.code === 401)) {
            const providerId = getHostedMcpOAuthProviderId(name, config);
            if (authStorage &&
                mcpOAuthStore &&
                !retriedAfterOAuthRefresh &&
                authStorage.get(providerId)?.type === 'oauth') {
                const refreshedToken = await authStorage.getApiKey(providerId, { forceRefresh: true });
                if (refreshedToken) {
                    return connectHostedServer(name, config, authStorage, mcpOAuthStore, true);
                }
            }
            const supportsOAuth = await supportsHostedMcpOAuth(name, config, mcpOAuthStore, {
                timeoutMs: resolveHostedMcpOAuthTimeoutMs(config)
            }).catch(() => !!config.oauth);
            const providerName = config.oauth?.providerName ?? `MCP: ${name}`;
            throw new McpConnectionError('auth_required', supportsOAuth
                ? `Authentication required for hosted MCP server '${name}'. Run /login and choose "${providerName}".`
                : `Authentication required for hosted MCP server '${name}'. Configure credentials and reconnect.`);
        }
        throw err;
    }
    const toolsResult = await client.listTools();
    const tools = (toolsResult.tools ?? []).map((t) => ({
        name: t.name,
        description: t.description,
        parameterNames: t.inputSchema?.properties
            ? Object.keys(t.inputSchema.properties)
            : [],
        inputSchema: (t.inputSchema ?? {})
    }));
    if (tools.length === 0) {
        log.info('mcp', `Hosted MCP server '${name}' connected but exposed 0 tools.`);
    }
    return { name, client, transport, tools, requestPolicy };
}
async function connectStdioServer(name, config, defaultCwd) {
    const startupTimeoutMs = resolveStartupTimeoutMs(config);
    const requestPolicy = buildRequestPolicy(config);
    let serverEnv = { ...process.env, ...(config.env ?? {}) };
    if (config.connection) {
        const secrets = getSecret(config.connection);
        if (!secrets) {
            throw new Error(`MCP server '${name}' references connection '${config.connection}' but no credentials found in keychain. Run /connect to set it up.`);
        }
        serverEnv = { ...serverEnv, ...secrets };
    }
    const transport = new StdioClientTransport({
        command: config.command,
        args: config.args,
        env: serverEnv,
        cwd: config.cwd ?? defaultCwd,
        stderr: 'pipe'
    });
    let stderrBuf = '';
    transport.stderr?.on('data', (chunk) => {
        stderrBuf += chunk.toString();
        if (stderrBuf.length > 4096) {
            stderrBuf = stderrBuf.slice(-2048);
        }
    });
    const client = new Client({ name: 'shortcut-agent', version: '1.0.0' }, { capabilities: {} });
    const connectPromise = client.connect(transport);
    let timer;
    const timeoutPromise = new Promise((_, reject) => {
        timer = setTimeout(() => reject(new Error(`did not respond within ${startupTimeoutMs / 1000}s`)), startupTimeoutMs);
        if (typeof timer === 'object' && 'unref' in timer)
            timer.unref();
    });
    try {
        await Promise.race([connectPromise, timeoutPromise]);
        if (timer)
            clearTimeout(timer);
    }
    catch (err) {
        try {
            await client.close();
        }
        catch {
            // Ignore close errors during cleanup.
        }
        const stderr = stderrBuf.trim();
        const base = err instanceof Error ? err.message : String(err);
        const detail = stderr ? `${base}\nServer stderr: ${stderr}` : base;
        throw new McpConnectionError('connect_failed', detail);
    }
    const toolsResult = await client.listTools();
    const tools = (toolsResult.tools ?? []).map((t) => ({
        name: t.name,
        description: t.description,
        parameterNames: t.inputSchema?.properties
            ? Object.keys(t.inputSchema.properties)
            : [],
        inputSchema: (t.inputSchema ?? {})
    }));
    if (tools.length === 0) {
        log.info('mcp', `MCP server '${name}' connected but exposed 0 tools.`);
    }
    return { name, client, transport, tools, requestPolicy };
}
/**
 * Connect a server, list tools, and return a live MCP connection.
 */
export async function connectServer(name, config, defaultCwd, authStorage, mcpOAuthStore) {
    if (isHostedServerConfig(config)) {
        return connectHostedServer(name, config, authStorage, mcpOAuthStore);
    }
    return connectStdioServer(name, config, defaultCwd);
}
/**
 * Call a tool on a connected server. Returns the result text or throws.
 */
export async function callTool(connection, toolName, args, signal, attemptedReconnect = false) {
    try {
        const requestOptions = {
            signal,
            timeout: connection.requestPolicy.timeoutMs,
            resetTimeoutOnProgress: connection.requestPolicy.resetTimeoutOnProgress,
            ...(connection.requestPolicy.resetTimeoutOnProgress ? { onprogress: () => { } } : {}),
            ...(connection.requestPolicy.maxTotalTimeoutMs !== undefined
                ? { maxTotalTimeout: connection.requestPolicy.maxTotalTimeoutMs }
                : {})
        };
        const result = await connection.client.callTool({ name: toolName, arguments: args ?? {} }, undefined, requestOptions);
        const isError = result.isError === true;
        const text = await normalizeToolResultText(connection.name, toolName, result);
        return { text, isError };
    }
    catch (err) {
        if (err instanceof Error && err.name === 'AbortError') {
            return { text: 'Tool call was cancelled.', isError: true };
        }
        if (err instanceof UnauthorizedError ||
            (err instanceof StreamableHTTPError && err.code === 401)) {
            if (!attemptedReconnect && connection.reconnect) {
                const freshConnection = await connection.reconnect();
                if (freshConnection) {
                    return callTool(freshConnection, toolName, args, signal, true);
                }
            }
            return {
                text: `MCP server '${connection.name}' requires re-authentication. Run /login and try again.`,
                isError: true
            };
        }
        const detail = err instanceof Error ? err.message : String(err);
        const msg = `MCP server '${connection.name}' error during tool call: ${detail}`;
        return { text: msg, isError: true };
    }
}
/**
 * Close a connection.
 */
export function closeConnection(connection) {
    connection.client.close().catch(() => { });
    if (connection.transport instanceof StdioClientTransport) {
        const killTimer = setTimeout(() => {
            try {
                connection.transport.close();
            }
            catch {
                // Already dead.
            }
        }, 5000);
        if (typeof killTimer === 'object' && 'unref' in killTimer)
            killTimer.unref();
    }
}
//# sourceMappingURL=connection.js.map