/**
 * MCP Connector Manager
 *
 * Connects the Studiograph agent to third-party MCP servers so the agent can
 * query external data sources on demand.
 *
 * Supports two transports:
 *   - HTTP (Streamable HTTP) — `url` + optional `headers`
 *   - Stdio — `command` + `args` + optional `env`
 *
 * Two-layer config:
 *
 *   Workspace layer (.studiograph/connectors/<name>.json)
 *     Non-secret transport config committed to git. Secrets replaced with
 *     $ENV_VAR placeholders resolved at runtime.
 *
 *   Homedir layer (~/.studiograph/connectors/<name>.json)
 *     Full config with literal secrets (mode 0600, never committed).
 *     When present, homedir fields override workspace fields.
 *
 * Each connector's tools are injected into the agent with a namespaced prefix:
 *   linear__search_issues, etc.
 *
 * Connection failures are non-fatal — the failing connector is skipped with a warning.
 */
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, unlinkSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { enforceConnectorConfig } from '../core/schemas/connector.js';
import { McpOAuthProvider } from './oauth-provider.js';
import { hasBrokerSupport } from './oauth-registry.js';
import { log } from '../utils/log.js';
export const CONNECTORS_DIR = join(homedir(), '.studiograph', 'connectors');
// ── ConnectorManager ──────────────────────────────────────────────────────────
export class ConnectorManager {
    // ── config persistence ────────────────────────────────────────────────────
    /**
     * Load all connector configs, merging workspace + homedir layers.
     */
    static loadConfigs(workspacePath) {
        const workspaceConfigs = workspacePath
            ? ConnectorManager._readDir(join(workspacePath, '.studiograph', 'connectors'))
            : [];
        const homedirConfigs = ConnectorManager._readDir(CONNECTORS_DIR);
        const merged = new Map();
        for (const c of workspaceConfigs) {
            merged.set(c.name, c);
        }
        for (const c of homedirConfigs) {
            const base = merged.get(c.name);
            merged.set(c.name, base ? ConnectorManager._merge(base, c) : c);
        }
        return Array.from(merged.values());
    }
    /**
     * Load connector configs from workspace .studiograph/connectors/ only.
     */
    static loadWorkspaceConfigs(workspacePath) {
        return ConnectorManager._readDir(join(workspacePath, '.studiograph', 'connectors'));
    }
    /**
     * Save full config to homedir (mode 0600, never committed).
     *
     * Phase 5 dual-write: when the encrypted store has been initialized,
     * the connector's secret-bearing fields (`headers.Authorization`,
     * `oauth.client_secret`) are mirrored into the store under
     * workspace-scope keys (`connector.<name>.auth`,
     * `connector.<name>.oauth_secret`). Failures never block the file
     * write — see dual-write.ts. The legacy file remains authoritative
     * through Phase 5; Phase 6 flipped the read path.
     */
    static saveConfig(config) {
        mkdirSync(CONNECTORS_DIR, { recursive: true });
        const path = join(CONNECTORS_DIR, `${config.name}.json`);
        writeFileSync(path, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
        void ConnectorManager._dualWrite(config);
    }
    /**
     * Lazy import of the dual-write helper so the secrets store is only
     * loaded when the flag is on. Best-effort — failures log + swallow.
     */
    static async _dualWrite(config) {
        try {
            const { mirrorConnectorConfig } = await import('../core/secrets/dual-write.js');
            await mirrorConnectorConfig(config);
        }
        catch (err) {
            log.warn(`[secrets] dual-write connector ${config.name} mirror failed: ${err instanceof Error ? err.message : String(err)}`);
        }
    }
    /** Save sanitized config to workspace .studiograph/connectors/ (git-tracked). */
    static saveWorkspaceConfig(config, workspacePath) {
        const dir = join(workspacePath, '.studiograph', 'connectors');
        mkdirSync(dir, { recursive: true });
        const stripped = ConnectorManager.stripSecrets(config);
        // Phase 9c: strict-or-warn schema enforcement on the workspace-layer
        // (the committed file). The stripped config SHOULD be free of literal
        // secrets — any violation here means stripSecrets missed something,
        // and in strict mode (default) we abort the write loudly. Callers
        // translate the throw into 400/exit-1 for the user. Set
        // STUDIOGRAPH_SCHEMA_STRICT=0 for the warn-mode fallback.
        enforceConnectorConfig(stripped);
        const path = join(dir, `${config.name}.json`);
        writeFileSync(path, JSON.stringify(stripped, null, 2) + '\n');
    }
    static removeConfig(name, workspacePath) {
        let removed = false;
        const hdPath = join(CONNECTORS_DIR, `${name}.json`);
        if (existsSync(hdPath)) {
            unlinkSync(hdPath);
            removed = true;
        }
        if (workspacePath) {
            const wsPath = join(workspacePath, '.studiograph', 'connectors', `${name}.json`);
            if (existsSync(wsPath)) {
                unlinkSync(wsPath);
                removed = true;
            }
        }
        return removed;
    }
    /** Load a single connector, merging workspace + homedir layers. */
    static loadConfig(name, workspacePath) {
        let wsConfig = null;
        if (workspacePath) {
            const wsPath = join(workspacePath, '.studiograph', 'connectors', `${name}.json`);
            if (existsSync(wsPath)) {
                try {
                    wsConfig = JSON.parse(readFileSync(wsPath, 'utf-8'));
                }
                catch { /* skip */ }
            }
        }
        let hdConfig = null;
        const hdPath = join(CONNECTORS_DIR, `${name}.json`);
        if (existsSync(hdPath)) {
            try {
                hdConfig = JSON.parse(readFileSync(hdPath, 'utf-8'));
            }
            catch { /* skip */ }
        }
        if (!wsConfig && !hdConfig)
            return null;
        if (!wsConfig)
            return hdConfig;
        if (!hdConfig)
            return wsConfig;
        return ConnectorManager._merge(wsConfig, hdConfig);
    }
    /**
     * Strip secret fields from a connector config, replacing them with $ENV_VAR
     * placeholders. Safe to commit to git.
     */
    static stripSecrets(config) {
        const stripped = { ...config };
        const prefix = config.name.toUpperCase().replace(/-/g, '_');
        // Strip Authorization header
        if (stripped.headers) {
            stripped.headers = { ...stripped.headers };
            for (const [key, value] of Object.entries(stripped.headers)) {
                if (key.toLowerCase() === 'authorization' && !value.startsWith('$')) {
                    stripped.headers[key] = `$${prefix}_AUTH`;
                }
            }
        }
        // Strip OAuth client secret
        if (stripped.oauth?.client_secret && !stripped.oauth.client_secret.startsWith('$')) {
            stripped.oauth = { ...stripped.oauth, client_secret: `$${prefix}_OAUTH_SECRET` };
        }
        return stripped;
    }
    /** Expands $ENV_VAR references; returns the literal string otherwise. */
    static resolveEnvVar(value) {
        return value.replace(/\$([A-Z_][A-Z0-9_]*)/g, (match, name) => process.env[name] ?? match);
    }
    // ── JSON config parser ───────────────────────────────────────────────────
    /**
     * Parse a JSON config block into ConnectorConfig(s).
     * Supports three formats:
     *   A) Single server: { command, args, env } or { url, headers }
     *   B) Named servers: { mcpServers: { name: { ... } } }
     *   C) Single named: { name: "...", command/url: "..." }
     */
    static parseConfigBlock(json, defaultName) {
        const parsed = JSON.parse(json);
        if (!parsed || typeof parsed !== 'object')
            throw new Error('Invalid JSON');
        // Format B: mcpServers wrapper
        if (parsed.mcpServers && typeof parsed.mcpServers === 'object') {
            const configs = Object.entries(parsed.mcpServers)
                .filter(([, config]) => config.url)
                .map(([name, config]) => ({
                name,
                ...ConnectorManager._normalizeServerConfig(config),
            }));
            if (configs.length === 0)
                throw new Error('No HTTP connectors found in mcpServers (only url-based connectors are supported)');
            return configs;
        }
        // Format C: has a name field
        if (parsed.name && parsed.url) {
            return [{ ...ConnectorManager._normalizeServerConfig(parsed), name: parsed.name }];
        }
        // Format A: single unnamed server
        if (parsed.url) {
            const name = defaultName || ConnectorManager._inferName(parsed);
            return [{ name, ...ConnectorManager._normalizeServerConfig(parsed) }];
        }
        throw new Error('Unrecognized config format. Expected { url } or { mcpServers: { ... } }');
    }
    static _normalizeServerConfig(config) {
        const result = {};
        if (config.url)
            result.url = config.url;
        if (config.headers)
            result.headers = config.headers;
        if (config.description)
            result.description = config.description;
        if (config.oauth)
            result.oauth = config.oauth;
        return result;
    }
    /** Try to infer a connector name from the URL. */
    static _inferName(config) {
        if (config.url) {
            try {
                const host = new URL(config.url).hostname;
                return host.replace(/^mcp\./, '').replace(/\.(com|io|app|dev)$/, '').replace(/\./g, '-');
            }
            catch { /* fall through */ }
        }
        return `connector-${Date.now()}`;
    }
    // ── tool building ─────────────────────────────────────────────────────────
    /**
     * For each connector config: connect to the MCP server, fetch its tool list,
     * and return tool objects with namespaced names.
     *
     * Auth precedence (single rule across the codebase):
     *   1. Per-user OAuth tokens (when `opts.userId` + `opts.authService` resolve
     *      `hasOAuthTokens === true`) — restores the original `McpOAuthProvider`
     *      design intent: tokens stored per `(user, connector)` are actually
     *      *used* upstream. The provider's `tokens()` returns the user's row;
     *      the SDK refreshes via `saveTokens` writing back to the same row.
     *   2. Workspace static `Authorization` header (`config.headers.Authorization`)
     *      — admin-configured shared identity. Used when the caller has no user
     *      session (CLI, MCP server, agent pool spawned without `userId`) or
     *      when the user hasn't bound their own OAuth credential yet.
     *   3. Skip — `requiresOAuth` connector with neither per-user OAuth nor a
     *      workspace header has no usable credential.
     *
     * Failures are non-fatal — the connector is skipped with a console.warn.
     */
    static async buildTools(configs, opts) {
        const timeoutMs = opts?.timeoutMs ?? 10_000;
        const verbose = process.env.STUDIOGRAPH_VERBOSE === '1';
        if (verbose) {
            log.info(`[connectors] buildTools: ${configs.length} config(s), userId=${opts?.userId ?? 'none'}, authService=${opts?.authService ? 'present' : 'missing'}`);
        }
        // Load all connectors concurrently. Sequential was N × timeoutMs in the
        // worst case (three dead MCP endpoints → 30s before the first chat callback).
        // Parallel collapses that to ~timeoutMs total since each connector's timer
        // runs concurrently. Order is preserved in the flattened tool list since
        // Promise.all resolves in input order.
        //
        // Per-connector containment: the entire body (auth resolution + connection)
        // runs inside the try, so a throw from `resolveConnectorAuth` (e.g. an
        // encrypted-row decrypt failure inside `hasOAuthTokens`) skips that one
        // connector instead of rejecting the whole batch and starving the agent.
        const results = await Promise.all(configs.map(async (config) => {
            let decisionMode = 'unknown';
            let timer;
            try {
                const decision = ConnectorManager.resolveConnectorAuth(config, opts);
                decisionMode = decision.mode;
                if (verbose) {
                    log.info(`[connectors] "${config.name}" → resolved as ${decision.mode}`);
                }
                if (decision.mode === 'skip-placeholder')
                    return [];
                if (decision.mode === 'skip-no-credential')
                    return [];
                const provider = decision.mode === 'user-oauth' ? decision.authProvider : undefined;
                // Clear the timer on success so fast connectors don't leave a
                // pending setTimeout sitting in the event loop until `timeoutMs`.
                return await Promise.race([
                    ConnectorManager._buildConnectorTools(config, provider),
                    new Promise((_, reject) => {
                        timer = setTimeout(() => reject(new Error('Connection timed out')), timeoutMs);
                    }),
                ]);
            }
            catch (err) {
                const msg = err instanceof Error ? err.message : String(err);
                const isAuthError = /unauthorized|invalid.token|authorization.*required|403/i.test(msg);
                // Broker-mediated connectors (Asana, Figma) hit a known runtime
                // gap when the user's tokens expire: the MCP SDK falls back to
                // Dynamic Client Registration against the upstream because we
                // don't carry the broker's client_secret in process, and the
                // upstream rejects the instance redirect URI ("One or more
                // redirect URIs are not allowed" / "redirect_uri" mismatch /
                // "unregistered" client). Surface as a reconnect prompt
                // instead of a generic failure — proper fix is refresh-via-
                // broker (see connector_per_user_oauth_gap.md).
                const isBrokerRedirectMismatch = decisionMode === 'user-oauth' &&
                    hasBrokerSupport(config.url) &&
                    /redirect.?uri|unregistered|not.allowed/i.test(msg);
                if (isBrokerRedirectMismatch) {
                    log.warn(`[connectors] Per-user OAuth tokens for "${config.name}" are stale and can't be refreshed automatically ` +
                        `(broker refresh not yet implemented). User must reconnect from Settings → External Connectors.`);
                    return [];
                }
                // Until 2026-05-13, ALL auth errors were silently suppressed
                // (legacy behavior from before the runtime could actually do
                // per-user OAuth). That's actively misleading now: when a user
                // has bound tokens via the UI but the upstream rejects them,
                // we need to surface that, not pretend the connector just
                // doesn't exist. Only suppress when we knowingly used the
                // workspace fallback path — that's the "admin-misconfigured
                // header" case where the old behavior is still right.
                if (isAuthError && decisionMode !== 'user-oauth')
                    return [];
                if (isAuthError && decisionMode === 'user-oauth') {
                    log.warn(`[connectors] Per-user OAuth call to "${config.name}" failed (token rejected upstream): ${msg}. ` +
                        `User may need to reconnect.`);
                    return [];
                }
                log.warn(`[connectors] Failed to connect to "${config.name}": ${msg}`);
                return [];
            }
            finally {
                if (timer)
                    clearTimeout(timer);
            }
        }));
        return results.flat();
    }
    /**
     * Decide which credential to use for a connector. Public so the precedence
     * rule can be unit-tested without spinning up a real MCP server.
     *
     * Returns one of:
     *   - `{ mode: 'user-oauth', authProvider }`: per-user OAuth tokens win. The
     *     transport will read tokens via the provider's `tokens()` and refresh
     *     back to the user's row.
     *   - `{ mode: 'workspace-header' }`: the workspace static `Authorization`
     *     header is used. The transport gets the header directly; the OAuth
     *     machinery is not touched.
     *   - `{ mode: 'skip-placeholder' }`: the workspace header contains an
     *     unresolved `$ENV_VAR` placeholder — nothing usable, skip silently.
     *   - `{ mode: 'skip-no-credential' }`: connector requires OAuth but has
     *     neither per-user tokens nor a workspace header.
     */
    static resolveConnectorAuth(config, opts) {
        // Unresolved env-var placeholder in the header — nothing usable.
        const authHeader = config.headers?.['Authorization'] ?? '';
        if (authHeader.includes('$') && ConnectorManager.resolveEnvVar(authHeader).includes('$')) {
            return { mode: 'skip-placeholder' };
        }
        const userHasOAuth = !!opts?.userId &&
            !!opts?.authService &&
            opts.authService.hasOAuthTokens(opts.userId, config.name);
        const hasWorkspaceHeader = !!config.headers?.['Authorization'];
        // Precedence: per-user OAuth wins over workspace header. See the
        // 2026-05-13 design note in docs/agents/agent-system.md (Per-user MCP OAuth).
        if (userHasOAuth) {
            const oauthCreds = ConnectorManager._resolveOAuthCredentials(config);
            const provider = new McpOAuthProvider(config.name, opts.userId, opts.authService, opts?.serverUrl ?? '', oauthCreds);
            return { mode: 'user-oauth', authProvider: provider };
        }
        if (hasWorkspaceHeader)
            return { mode: 'workspace-header' };
        if (config.requiresOAuth)
            return { mode: 'skip-no-credential' };
        // No auth needed (open MCP server).
        return { mode: 'workspace-header' };
    }
    /** Resolve OAuth credentials from a connector config or fall back to broker. */
    static _resolveOAuthCredentials(config) {
        if (config.oauth?.client_id) {
            return {
                client_id: config.oauth.client_id,
                client_secret: config.oauth.client_secret,
            };
        }
        // Broker-supported connectors don't carry credentials locally — the broker
        // holds the client secret. McpOAuthProvider tolerates `undefined` here
        // because at runtime it only needs `tokens()`, which reads the user's row.
        if (hasBrokerSupport(config.url))
            return undefined;
        return undefined;
    }
    // ── private helpers ───────────────────────────────────────────────────────
    static _readDir(dir) {
        if (!existsSync(dir))
            return [];
        let entries;
        try {
            entries = readdirSync(dir);
        }
        catch {
            return [];
        }
        const configs = [];
        for (const entry of entries) {
            if (!entry.endsWith('.json'))
                continue;
            try {
                const raw = JSON.parse(readFileSync(join(dir, entry), 'utf-8'));
                if (typeof raw.name !== 'string' || !raw.name) {
                    log.warn(`[connectors] Skipping malformed config (missing name): ${entry}`);
                    continue;
                }
                if (!ConnectorManager._validate(raw, entry))
                    continue;
                configs.push(raw);
            }
            catch {
                log.warn(`[connectors] Could not parse ${entry} — skipping`);
            }
        }
        return configs;
    }
    static _merge(base, overlay) {
        const merged = { ...base };
        for (const [key, value] of Object.entries(overlay)) {
            if (value !== undefined && value !== null) {
                if (key === 'headers' && merged.headers) {
                    merged.headers = { ...merged.headers, ...value };
                }
                else {
                    merged[key] = value;
                }
            }
        }
        return merged;
    }
    static _validate(config, filename) {
        if (!config.url) {
            // Partially-configured connectors (added in the UI but never given a
            // URL) are an expected idle state — connector status is surfaced in
            // the Settings UI, no need to warn on every boot. Verbose mode keeps
            // the visibility for debugging.
            if (process.env.STUDIOGRAPH_VERBOSE === '1') {
                log.warn(`[connectors] connector "${config.name}" missing "url" — skipping`);
            }
            return false;
        }
        return true;
    }
    static async _buildConnectorTools(config, authProvider) {
        const client = new Client({ name: 'studiograph', version: '1.0.0' });
        // When per-user OAuth wins, hand the transport the OAuthClientProvider —
        // it manages the Authorization header from `provider.tokens()`, including
        // refreshing via `saveTokens` writing back to the user's row. We
        // deliberately do NOT also pass the workspace static header in this case
        // (precedence rule: user OAuth wins outright).
        //
        // When user OAuth is absent, headers from config are passed verbatim —
        // env-placeholder resolution preserved.
        const transportOpts = {};
        if (authProvider) {
            transportOpts.authProvider = authProvider;
        }
        else {
            const headers = {};
            if (config.headers) {
                for (const [key, value] of Object.entries(config.headers)) {
                    headers[key] = ConnectorManager.resolveEnvVar(value);
                }
            }
            if (Object.keys(headers).length > 0)
                transportOpts.requestInit = { headers };
        }
        const transport = new StreamableHTTPClientTransport(new URL(config.url), transportOpts);
        await client.connect(transport);
        const { tools: remoteTools } = await client.listTools();
        const label = config.description ?? config.name;
        return remoteTools.map(tool => ({
            name: `${config.name}__${tool.name}`,
            label: `${label}: ${tool.title ?? tool.name}`,
            description: `[${config.name}] ${tool.description ?? tool.name}`,
            parameters: tool.inputSchema,
            execute: async (_toolCallId, params) => {
                try {
                    const result = await client.callTool({ name: tool.name, arguments: params });
                    const text = result.content
                        .filter((c) => c.type === 'text')
                        .map((c) => c.text)
                        .join('\n');
                    return {
                        content: [{ type: 'text', text: text || JSON.stringify(result, null, 2) }],
                        details: result,
                    };
                }
                catch (err) {
                    const msg = err instanceof Error ? err.message : String(err);
                    return {
                        content: [{ type: 'text', text: `Error calling ${config.name}__${tool.name}: ${msg}` }],
                        isError: true,
                    };
                }
            },
        }));
    }
}
//# sourceMappingURL=connector-manager.js.map