// Install flow — creates connection, handles OAuth, creates MCP server config, // mints API key, and writes to ~/.copilot/mcp-config.json. import { promises as fs } from "node:fs"; import { spawn } from "node:child_process"; import { randomBytes } from "node:crypto"; import { homedir, platform } from "node:os"; import { dirname, join } from "node:path"; import { getToken, assertArmHost, armSegment, resolveSystemExecutable } from "./armClient.mjs"; const COPILOT_HOME = process.env.COPILOT_HOME || join(homedir(), ".copilot"); // Two scopes the Copilot CLI reads MCP servers from: // profile -> ~/.copilot/mcp-config.json (private, follows you everywhere) // workspace -> /.mcp.json (shared with the repo, git-tracked) const PROFILE_MCP_PATH = join(COPILOT_HOME, "mcp-config.json"); const PENDING_CLEANUP_DIR = join(COPILOT_HOME, "extensions", "connector-namespaces", "artifacts", "pending-cleanup"); let s_workspaceRoot = null; export function setWorkspaceRoot(path) { s_workspaceRoot = path || null; } export function getWorkspaceRoot() { return s_workspaceRoot; } function mcpConfigPath(scope) { if (scope === "workspace") { if (!s_workspaceRoot) throw new Error("No workspace folder is available for this session."); return join(s_workspaceRoot, ".mcp.json"); } return PROFILE_MCP_PATH; } const CONFIG_LOCK_TIMEOUT_MS = 10_000; const CONFIG_LOCK_STALE_MS = 30_000; // Serialize in-process writes, then hold an exclusive lock file so separate // Copilot sessions cannot overwrite each other's MCP config changes. let s_configLock = Promise.resolve(); async function acquireConfigLock(path) { const lockPath = `${path}.lock`; await fs.mkdir(dirname(path), { recursive: true, mode: 0o700 }); const deadline = Date.now() + CONFIG_LOCK_TIMEOUT_MS; for (;;) { const owner = `${process.pid}:${randomBytes(12).toString("hex")}\n`; try { const handle = await fs.open(lockPath, "wx", 0o600); await handle.writeFile(owner, "utf8"); return async () => { await handle.close(); try { if (await fs.readFile(lockPath, "utf8") === owner) { await fs.unlink(lockPath); } } catch (error) { if (error?.code !== "ENOENT") throw error; } }; } catch (error) { if (error?.code !== "EEXIST") throw error; try { const stat = await fs.stat(lockPath); if (Date.now() - stat.mtimeMs > CONFIG_LOCK_STALE_MS) { const stalePath = `${lockPath}.${process.pid}.${randomBytes(6).toString("hex")}.stale`; await fs.rename(lockPath, stalePath); await fs.unlink(stalePath); continue; } } catch (statError) { if (statError?.code === "ENOENT") continue; throw statError; } if (Date.now() >= deadline) { throw new Error(`Timed out waiting for the MCP config lock at ${lockPath}.`); } await sleep(50); } } } export async function waitForConnected(config, connName, options = {}) { const maxPolls = options.maxPolls ?? 20; const delay = options.delay ?? sleep; const getStatus = options.getStatus ?? getConnectionStatus; let status = "Unknown"; for (let i = 0; i < maxPolls; i++) { status = await getStatus(config, connName); if (status === "Connected") return status; if (i + 1 < maxPolls) await delay(1000); } throw new Error(`Connection ended in state "${status}".`); } function withConfigLock(path, fn) { const run = s_configLock.then(async () => { const release = await acquireConfigLock(path); try { return await fn(); } finally { await release(); } }, async () => { const release = await acquireConfigLock(path); try { return await fn(); } finally { await release(); } }); s_configLock = run.then(() => {}, () => {}); return run; } async function readPendingCleanups(gateway, apiName) { let names; try { names = await fs.readdir(PENDING_CLEANUP_DIR); } catch (error) { if (error?.code === "ENOENT") return []; throw error; } const records = []; for (const name of names) { if (!name.endsWith(".json")) continue; const path = join(PENDING_CLEANUP_DIR, name); let record; try { record = JSON.parse(await fs.readFile(path, "utf8")); } catch (error) { if (error?.code === "ENOENT") continue; throw error; } if (!record || typeof record !== "object" || Array.isArray(record)) { throw new Error("Pending connector cleanup data is invalid."); } if (record.gatewayId === gateway && record.apiName === apiName) { records.push({ ...record, path }); } } return records; } async function getPendingCleanup(gateway, apiName) { const records = await readPendingCleanups(gateway, apiName); if (!records.length) return null; return { configNames: records.flatMap((record) => Array.isArray(record.configNames) ? record.configNames : []), connectionNames: records.flatMap((record) => Array.isArray(record.connectionNames) ? record.connectionNames : []), journalFiles: records.map((record) => record.path), }; } async function savePendingCleanup(record) { await fs.mkdir(PENDING_CLEANUP_DIR, { recursive: true, mode: 0o700 }); await fs.chmod(PENDING_CLEANUP_DIR, 0o700).catch(() => {}); const id = randomBytes(16).toString("hex"); const tempPath = join(PENDING_CLEANUP_DIR, `${id}.tmp`); const path = join(PENDING_CLEANUP_DIR, `${id}.json`); await fs.writeFile(tempPath, JSON.stringify(record, null, 2) + "\n", { encoding: "utf8", mode: 0o600, flag: "wx" }); try { await fs.chmod(tempPath, 0o600).catch(() => {}); await fs.rename(tempPath, path); return path; } catch (error) { try { await fs.unlink(tempPath); } catch (cleanupError) { if (cleanupError?.code !== "ENOENT") { throw new AggregateError([error, cleanupError], "Failed to save connector cleanup retry data."); } } throw error; } } async function clearPendingCleanups(paths) { for (const path of new Set(paths)) { try { await fs.unlink(path); } catch (error) { if (error?.code !== "ENOENT") throw error; } } } // Validate the MCP endpoint URL before persisting it alongside an API key. The // value comes from an authenticated ARM read of the user's own gateway, so this // is defense in depth: require https, reject embedded credentials, and block // obvious internal/link-local hosts. export function assertSafeMcpTarget(rawUrl) { let u; try { u = new URL(rawUrl); } catch { throw new Error("MCP endpoint URL is not a valid URL."); } if (u.protocol !== "https:") throw new Error("MCP endpoint URL must use https."); if (u.username || u.password) throw new Error("MCP endpoint URL must not embed credentials."); const host = u.hostname.replace(/^\[|\]$/g, "").toLowerCase(); const isIpv6 = host.includes(":"); const blocked = host === "localhost" || host.endsWith(".localhost") || host === "metadata.google.internal" || host === "0.0.0.0" || (isIpv6 && (host === "::1" || host.startsWith("fe80:") || host.startsWith("fc") || host.startsWith("fd"))) || /^(127|10)\./.test(host) || /^169\.254\./.test(host) || /^192\.168\./.test(host) || /^172\.(1[6-9]|2\d|3[01])\./.test(host); if (blocked) throw new Error(`MCP endpoint URL host is not allowed: ${host}`); } // --------------------------------------------------------------------------- // ARM helpers (using the shared token) // --------------------------------------------------------------------------- // ARM occasionally answers with a transient 5xx/429 (backend blip, throttling) // that clears on a retry. A single one of these shouldn't nuke a whole connect // flow, so arm() retries them a few times with backoff before surfacing. const ARM_TRANSIENT = new Set([429, 500, 502, 503, 504]); const ARM_MAX_ATTEMPTS = 3; const ARM_BACKOFF_MS = 500; async function arm(method, url, body) { const token = await getToken(); const headers = { Authorization: `Bearer ${token}`, Accept: "application/json" }; if (body !== undefined) headers["Content-Type"] = "application/json"; const fullUrl = url.startsWith("http") ? url : `https://management.azure.com${url}`; // Guard the exact value handed to fetch so a tainted path segment can never // redirect the call off ARM. assertArmHost throws unless fullUrl targets // https://management.azure.com/. const safeUrl = assertArmHost(fullUrl); for (let attempt = 1; ; attempt++) { const res = await fetch(safeUrl, { method, headers, body: body !== undefined ? JSON.stringify(body) : undefined, }); const text = await res.text(); let parsed; try { parsed = text ? JSON.parse(text) : undefined; } catch { parsed = text; } if (res.ok) return parsed; const msg = parsed?.error?.message ?? parsed?.message ?? text ?? `HTTP ${res.status}`; const err = new Error(`ARM ${method} ${res.status}: ${msg}`); err.status = res.status; // Every ARM call we make is idempotent (GET/PUT/DELETE) or a list-shaped // POST (listConsentLinks, listApiKey) that returns the same value on // retry, so retrying a transient failure can't spawn duplicate side // effects. A 500 is never treated like a 404 elsewhere, so a blip can't // trigger teardown of a live resource. if (!ARM_TRANSIENT.has(res.status) || attempt >= ARM_MAX_ATTEMPTS) throw err; await sleep(ARM_BACKOFF_MS * Math.pow(3, attempt - 1)); // 0.5s, then 1.5s } } // DELETE that tolerates "already gone" (404) but surfaces every other failure // instead of silently swallowing it. async function armDelete(url) { try { return await arm("DELETE", url); } catch (e) { if (e.status === 404) return undefined; throw e; } } // --------------------------------------------------------------------------- // Naming helpers // --------------------------------------------------------------------------- function shortId() { return randomBytes(3).toString("hex"); } function sanitize(s) { return String(s).replace(/[^a-zA-Z0-9]+/g, "").slice(0, 24) || "x"; } function generateName(displayName) { return `${sanitize(displayName)}-${shortId()}`; } function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } // --------------------------------------------------------------------------- // Connector metadata (connection parameters + agentic operation id) // --------------------------------------------------------------------------- const MANAGED_API_VERSION = "2022-09-01-preview"; const metaCache = new Map(); // apiName + swagger requirement -> Promise export function loadConnectorMeta(config, apiName, location, requireSwagger = true) { const sub = armSegment(config.subscriptionId); const cacheKey = `${sub}:${location}:${apiName}:${requireSwagger}`; if (metaCache.has(cacheKey)) return metaCache.get(cacheKey); const promise = (async () => { const base = `/subscriptions/${sub}/providers/Microsoft.Web/locations/${armSegment(location)}/managedApis/${armSegment(apiName)}`; const metaRequest = arm("GET", `${base}?api-version=${MANAGED_API_VERSION}`); const swaggerRequest = requireSwagger ? arm("GET", `${base}?api-version=${MANAGED_API_VERSION}&export=true`) : Promise.resolve(undefined); const [meta, swagger] = await Promise.all([metaRequest, swaggerRequest]); return { connectionParameters: meta?.properties?.connectionParameters ?? null, connectionParameterSets: meta?.properties?.connectionParameterSets ?? null, opId: swagger ? getMcpServerOperationId(swagger) : undefined, }; })(); // Cache the in-flight promise so a fast Connect click reuses the prewarm // fetch instead of starting a second swagger export. Evict on hard failure // so a transient error doesn't poison the cache. promise.catch(() => metaCache.delete(cacheKey)); metaCache.set(cacheKey, promise); return promise; } // Fire-and-forget pre-warm so the slow swagger fetch happens while the user is // reading the catalog, not when they click Connect. Concurrency is bounded so a // large catalog (~43 MCP servers, each 2 ARM GETs) doesn't burst ~86 parallel // requests on open and trip rate limits. Items are warmed in catalog order, so // the servers nearest the top of the view warm first. export function prewarmMeta(config, apiNames, location) { Promise.resolve(location || getGatewayLocation(config)).then(async (loc) => { const sub = armSegment(config.subscriptionId); const pending = apiNames.filter((name) => !metaCache.has(`${sub}:${loc}:${name}:true`)); const CONCURRENCY = 5; let next = 0; const worker = async () => { while (next < pending.length) { const apiName = pending[next++]; await loadConnectorMeta(config, apiName, loc).catch(() => {}); } }; const poolSize = Math.min(CONCURRENCY, pending.length); await Promise.all(Array.from({ length: poolSize }, worker)); }).catch(() => {}); } function getMcpServerOperationId(swagger) { if (!swagger?.paths) return undefined; for (const methods of Object.values(swagger.paths)) { if (!methods || typeof methods !== "object") continue; const post = methods.post; if (!post?.operationId) continue; const tags = (post.tags ?? []).map((t) => String(t).toLowerCase()); if (tags.includes("deprecated")) continue; if (tags.includes("agentic")) return post.operationId; } return undefined; } // Find the OAuth connection parameter name. The consent call 500s if we send a // parameterName the connector doesn't declare, so derive it from metadata. function findOAuthParam(meta, redirectUrl) { const fallback = { parameterName: "token", redirectUrl }; let params; if (meta?.connectionParameterSets?.values?.length) { params = meta.connectionParameterSets.values[0].parameters; } else { params = meta?.connectionParameters; } if (!params) return fallback; for (const [name, param] of Object.entries(params)) { if (param?.type === "oauthSetting" || param?.oAuthSettings) { return { parameterName: name, redirectUrl }; } } return fallback; } // --------------------------------------------------------------------------- // JWT decode (to get user oid/tid for access policy) // --------------------------------------------------------------------------- function decodeJwtPayload(token) { const parts = token.split("."); if (parts.length < 2) throw new Error("Invalid JWT"); const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); const pad = b64.length % 4 === 0 ? "" : "=".repeat(4 - (b64.length % 4)); return JSON.parse(Buffer.from(b64 + pad, "base64").toString("utf8")); } async function getUserContext() { const token = await getToken(); const claims = decodeJwtPayload(token); return { objectId: claims.oid, tenantId: claims.tid }; } // --------------------------------------------------------------------------- // Connection management // --------------------------------------------------------------------------- function gatewayId(config) { return `/subscriptions/${armSegment(config.subscriptionId)}/resourceGroups/${armSegment(config.resourceGroup)}/providers/Microsoft.Web/connectorGateways/${armSegment(config.gatewayName)}`; } const API_VERSION = "2026-05-01-preview"; const s_locationCache = new Map(); // gatewayId -> location (immutable per gateway) export async function getGatewayLocation(config) { const id = gatewayId(config); const cached = s_locationCache.get(id); if (cached) return cached; const gw = await arm("GET", `${id}?api-version=${API_VERSION}`); const loc = (gw.location ?? "").toLowerCase().replace(/\s+/g, ""); if (loc) s_locationCache.set(id, loc); return loc; } export async function createConnection(config, apiName, displayName, location) { const connName = generateName(displayName); await arm("PUT", `${gatewayId(config)}/connections/${connName}?api-version=${API_VERSION}`, { location, properties: { displayName, connectorName: apiName }, }); // Grant current user access policy try { const { objectId, tenantId } = await getUserContext(); await arm("PUT", `${gatewayId(config)}/connections/${connName}/accessPolicies/user-${shortId()}?api-version=${API_VERSION}`, { location, properties: { principal: { type: "ActiveDirectory", identity: { objectId, tenantId } } }, }); } catch { /* non-fatal */ } return connName; } export async function getConsentUrl(config, connName, callbackUrl, oauthParam) { const param = oauthParam || { parameterName: "token", redirectUrl: callbackUrl }; const res = await arm("POST", `${gatewayId(config)}/connections/${armSegment(connName)}/listConsentLinks?api-version=${API_VERSION}`, { parameters: [{ parameterName: param.parameterName, redirectUrl: param.redirectUrl }], }); return res?.value?.[0]?.link || null; } export async function getConnectionStatus(config, connName) { const conn = await arm("GET", `${gatewayId(config)}/connections/${armSegment(connName)}?api-version=${API_VERSION}`); return conn?.properties?.statuses?.[0]?.status ?? conn?.properties?.overallStatus ?? "Unknown"; } export async function createMcpServerConfig(config, apiName, displayName, connName, location, opId, configName = generateName(displayName)) { if (!opId) { throw new Error(`Cannot configure "${displayName}" as an MCP server: no agentic operation was found in the connector's definition. The connector may not expose an MCP-streamable endpoint, or its swagger failed to load.`); } const created = await arm("PUT", `${gatewayId(config)}/mcpserverConfigs/${configName}?api-version=${API_VERSION}`, { kind: "ManagedMcpServer", location, properties: { description: displayName, state: "Enabled", disableApiKeyAuth: false, // TextOnlyContent must stay false: when true the dataplane wraps tools/list and // tools/call responses in a base64 "$content" envelope that spec-compliant MCP // clients cannot parse, so zero tools load. settings: { TextOnlyContent: false }, connectors: [{ name: apiName, connectionName: connName, displayName, operations: [{ name: opId, displayName, description: "" }], }], }, }); return { configName, endpointUrl: created?.properties?.mcpEndpointUrl || null }; } export async function mintApiKey(config, configName) { const notAfter = new Date(Date.now() + 365 * 24 * 3600_000).toISOString(); const res = await arm("POST", `${gatewayId(config)}/listApiKey?api-version=${API_VERSION}`, { keyType: "Primary", notAfter, scope: configName, }); return res.key; } export async function getMcpEndpointUrl(config, configName) { const cfg = await arm("GET", `${gatewayId(config)}/mcpserverConfigs/${armSegment(configName)}?api-version=${API_VERSION}`); return cfg?.properties?.mcpEndpointUrl || null; } // --------------------------------------------------------------------------- // MCP config writer // --------------------------------------------------------------------------- async function readMcpConfigAt(path) { try { const raw = await fs.readFile(path, "utf8"); let parsed = JSON.parse(raw); // Reject arrays and primitives before treating this as a config object. // JSON.parse can return either (a hand-edited "[]" or a bare number), // and both break the write path: a string key set on an array is // silently dropped by JSON.stringify (the new entry would vanish), and // a primitive throws on property assignment. Fall back to a fresh // object so writeMcpEntry always persists. if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) parsed = {}; if (!parsed.mcpServers || typeof parsed.mcpServers !== "object" || Array.isArray(parsed.mcpServers)) parsed.mcpServers = {}; return parsed; } catch (e) { if (e.code === "ENOENT") return { mcpServers: {} }; throw e; } } async function writeMcpConfigAt(path, cfg) { const directory = dirname(path); const temporary = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`; await fs.mkdir(directory, { recursive: true, mode: 0o700 }); await fs.chmod(directory, 0o700).catch(() => {}); try { await fs.writeFile(temporary, JSON.stringify(cfg, null, 2) + "\n", { encoding: "utf8", mode: 0o600 }); await fs.chmod(temporary, 0o600).catch(() => {}); await fs.rename(temporary, path); await fs.chmod(path, 0o600).catch(() => {}); } finally { await fs.unlink(temporary).catch((error) => { if (error?.code !== "ENOENT") throw error; }); } } export async function writeMcpEntry(name, url, key, scope = "profile", meta = null) { assertSafeMcpTarget(url); const path = mcpConfigPath(scope); return withConfigLock(path, async () => { const cfg = await readMcpConfigAt(path); cfg.mcpServers[name] = { url, headers: { "X-API-Key": key }, }; // Stamp ARM provenance as a sibling metadata key. The underscore prefix // marks it as "metadata, not part of the MCP launch spec" — the CLI // tolerates and preserves unknown sibling keys across restarts. if (meta) cfg.mcpServers[name]._connectorNamespace = meta; await writeMcpConfigAt(path, cfg); }); } // Remove the entry from whichever scope(s) it lives in. export async function removeMcpEntry(name) { let removed = false; for (const scope of ["profile", "workspace"]) { let path; try { path = mcpConfigPath(scope); } catch { continue; } const removedAtPath = await withConfigLock(path, async () => { const cfg = await readMcpConfigAt(path); if (Object.prototype.hasOwnProperty.call(cfg.mcpServers, name)) { delete cfg.mcpServers[name]; await writeMcpConfigAt(path, cfg); return true; } return false; }); removed ||= removedAtPath; } return removed; } async function deleteMcpServerConfigs(config, configNames) { for (const configName of configNames) { const url = `${gatewayId(config)}/mcpserverConfigs/${armSegment(configName)}?api-version=${API_VERSION}`; await armDelete(url); let pending = true; for (let i = 0; i < 20; i++) { try { await arm("GET", url); } catch (error) { if (error?.status === 404) { pending = false; break; } throw error; } await sleep(750); } if (pending) throw new Error(`Timed out waiting for connector configuration "${configName}" deletion.`); } } async function cleanupConnectorResources(config, apiName, configNames, connectionNames, priorJournalFiles = []) { configNames = [...new Set(configNames.filter(Boolean))]; connectionNames = [...new Set(connectionNames.filter(Boolean))]; const gateway = gatewayId(config); const journalFile = await savePendingCleanup({ gatewayId: gateway, apiName, configNames, connectionNames }); const journalFiles = [...priorJournalFiles, journalFile]; await deleteMcpServerConfigs(config, configNames); for (const connectionName of connectionNames) { await armDelete(`${gateway}/connections/${armSegment(connectionName)}?api-version=${API_VERSION}`); } for (const configName of configNames) { await removeMcpEntry(configName); } await clearPendingCleanups(journalFiles); } // Remove an installed connector: delete its mcpserverConfig, its connection, // and its CLI entry. apiName is resolved against the current installed state. export async function uninstallConnector(config, apiName) { const state = await getInstalledState(config); const entry = state[apiName]; const gateway = gatewayId(config); const pending = await getPendingCleanup(gateway, apiName); if (!entry && !pending) return { ok: true, removed: false }; const candidates = entry ? (entry._candidates ?? [entry]) : []; const configNames = [ ...(pending?.configNames ?? []), ...candidates.map((candidate) => candidate.configName), ]; const connectionNames = [ ...(pending?.connectionNames ?? []), ...candidates.map((candidate) => candidate.connectionName), ]; await cleanupConnectorResources(config, apiName, configNames, connectionNames, pending?.journalFiles); return { ok: true, removed: true }; } // Local-only remove: drop just the CLI mcp entry, leaving the namespace // resources (mcpserverConfig + connection) intact. This is the default // "Remove" action — it unwires the connector from Copilot without deleting // anything on Azure. Fast and local; no armDelete, no convergence poll. export async function removeLocalEntry(config, apiName) { const state = await getInstalledState(config); const entry = state[apiName]; if (!entry) return { ok: true, removed: false }; const candidates = entry._candidates ?? [entry]; for (const candidate of candidates) { if (candidate.inCli && candidate.configName) await removeMcpEntry(candidate.configName); } return { ok: true, removed: true }; } // Best-effort rollback of a connection created during an install the user then // cancelled. At that point no mcpserverConfig exists yet, so uninstallConnector // can't see it — delete the orphaned connection directly so the tile honestly // returns to "Connect" and we don't leak a half-made connection on the namespace. export async function deleteConnection(config, connName) { if (!connName) return { ok: true, removed: false }; await armDelete(`${gatewayId(config)}/connections/${armSegment(connName)}?api-version=${API_VERSION}`); return { ok: true, removed: true }; } async function throwAfterCleanup(error, cleanups) { for (const cleanup of cleanups) { try { await cleanup(); } catch (cleanupError) { throw new AggregateError( [error, cleanupError], `${error.message} Cleanup also failed: ${cleanupError.message}`, ); } } throw error; } // --------------------------------------------------------------------------- // Full install pipeline // --------------------------------------------------------------------------- function oauthCallbackUrl(callbackBase, connName, capabilityToken = "") { const url = new URL(`${callbackBase}${encodeURIComponent(connName)}`); if (capabilityToken) { url.searchParams.set("cn_token", capabilityToken); } return url.toString(); } export async function installConnector(config, apiName, displayName, callbackBase, scope = "profile", capabilityToken = "") { const pending = await getPendingCleanup(gatewayId(config), apiName); if (pending) { await cleanupConnectorResources( config, apiName, pending.configNames, pending.connectionNames, pending.journalFiles, ); } const location = await getGatewayLocation(config); const meta = await loadConnectorMeta(config, apiName, location); // 1. Create connection const connName = await createConnection(config, apiName, displayName, location); let finishStarted = false; try { // The OAuth redirect must carry the connName so the loopback callback keys // pendingOAuth by the same value the client polls on. const callbackUrl = oauthCallbackUrl(callbackBase, connName, capabilityToken); // 2. Quick wait for the connection to converge — some connectors come up // Connected without any OAuth (e.g. service principal / key based). await sleep(800); const status = await getConnectionStatus(config, connName); if (status === "Connected") { finishStarted = true; return await finishInstall(config, apiName, displayName, connName, location, scope); } // 3. Needs OAuth — derive the correct consent parameter from metadata. const oauthParam = findOAuthParam(meta, callbackUrl); const consentUrl = await getConsentUrl(config, connName, callbackUrl, oauthParam); if (consentUrl) { return { needsConsent: true, consentUrl, connName, location, freshConnection: true }; } // 4. No consent link and not Connected — try to finish anyway. finishStarted = true; return await finishInstall(config, apiName, displayName, connName, location, scope); } catch (error) { if (finishStarted) throw error; return throwAfterCleanup(error, [() => deleteConnection(config, connName)]); } } export async function finishInstall(config, apiName, displayName, connName, location, scope = "profile") { let configName; try { if (!location) location = await getGatewayLocation(config); const meta = await loadConnectorMeta(config, apiName, location); // Poll connection status up to ~20s for Connected. const status = await waitForConnected(config, connName); // Create MCP server config (endpoint URL comes back on the PUT response). let endpointUrl; configName = generateName(displayName); ({ endpointUrl } = await createMcpServerConfig(config, apiName, displayName, connName, location, meta.opId, configName)); // Endpoint URL can lag — poll the config a few times if missing. for (let i = 0; !endpointUrl && i < 5; i++) { await sleep(1000); endpointUrl = await getMcpEndpointUrl(config, configName); } if (!endpointUrl) throw new Error(`MCP endpoint URL not available (connection status: ${status}).`); // Mint key and write the CLI entry, stamped with ARM provenance so the // install can be recognised regardless of which connector namespace is // active when state is next derived. const key = await mintApiKey(config, configName); const gw = gatewayId(config); await writeMcpEntry(configName, endpointUrl, key, scope, { gatewayId: gw, mcpServerConfigId: `${gw}/mcpserverConfigs/${armSegment(configName)}`, connectionId: `${gw}/connections/${armSegment(connName)}`, apiName, }); return { ok: true, configName, connName, endpointUrl, scope }; } catch (error) { const cleanups = []; if (configName) { cleanups.push(() => deleteMcpServerConfigs(config, [configName])); } cleanups.push(() => deleteConnection(config, connName)); return throwAfterCleanup(error, cleanups); } } // --------------------------------------------------------------------------- // Re-authenticate pipeline (reuse the EXISTING connection + config) // --------------------------------------------------------------------------- // Re-run consent for a connector that's already installed, WITHOUT minting a new // connection or a new mcpserverConfig. This is what the "Re-authenticate" button // hits; wiring it to the plain install path is exactly what spawned duplicate // configs. We resolve the selected install-state candidate (post phase-1 // selection, that's the config the local session actually points at), re-consent // its existing connection, and rebind THAT config locally. // // Falls back to a fresh installConnector only when there's genuinely nothing to // re-auth against: no known connection, or the stored connection was deleted // server-side (listConsentLinks 404s). In the 404 case we drop the orphaned // config + local entry first so the fallback install can't leave a duplicate. export async function reauthConnector(config, apiName, displayName, callbackBase, scope = "profile", capabilityToken = "") { return reauthConnectorWithAttempts(config, apiName, displayName, callbackBase, scope, capabilityToken, new Set()); } async function reauthConnectorWithAttempts(config, apiName, displayName, callbackBase, scope, capabilityToken, attemptedConfigNames) { const state = await getInstalledState(config); const selected = state[apiName]; const candidates = selected?._candidates ?? (selected ? [selected] : []); const entry = candidates.find((candidate) => !attemptedConfigNames.has(candidate.configName)); const connName = entry?.connectionName; // Nothing installed to re-auth against — treat it as a first-time Connect. if (!connName) { return installConnector(config, apiName, displayName, callbackBase, scope, capabilityToken); } const location = await getGatewayLocation(config); const meta = await loadConnectorMeta(config, apiName, location, false); const callbackUrl = oauthCallbackUrl(callbackBase, connName, capabilityToken); const oauthParam = findOAuthParam(meta, callbackUrl); let consentUrl; try { consentUrl = await getConsentUrl(config, connName, callbackUrl, oauthParam); } catch (err) { // The stored connection is gone (deleted in the portal). Clean up the // now-orphaned config + local entry, then fall through to a clean // install so we don't strand a dead "Re-authenticate" tile. if (err.status === 404) { attemptedConfigNames.add(entry.configName); const siblingUsesConnection = candidates.some( (candidate) => candidate.configName !== entry.configName && candidate.connectionName === connName, ); await cleanupConnectorResources( config, apiName, entry.configName ? [entry.configName] : [], siblingUsesConnection ? [] : [connName], ); return reauthConnectorWithAttempts( config, apiName, displayName, callbackBase, scope, capabilityToken, attemptedConfigNames, ); } throw err; } // Re-consent the existing connection; finish rebinds the SAME config. // configName is carried through so the finish step never creates a new one. if (consentUrl) { return { needsConsent: true, consentUrl, connName, location, configName: entry.configName, reauth: true, freshConnection: false }; } // Already consentable without a redirect — just rebind the existing config. return finishReauth(config, apiName, displayName, connName, entry.configName, location, scope); } // Finish a re-auth: rebind an EXISTING mcpserverConfig to the local CLI. Unlike // finishInstall this never calls createMcpServerConfig, so a re-auth can't spawn // a duplicate — it reuses configName, mints a fresh key, and rewrites the entry. export async function finishReauth(config, apiName, displayName, connName, configName, location, scope = "profile") { // Defensive: with no config to bind there's nothing to reuse — fall back to // a normal finish (which creates one). Shouldn't happen on the reauth path. if (!configName) { return finishInstall(config, apiName, displayName, connName, location, scope); } // Wait for the re-consented connection to converge (up to ~20s). const status = await waitForConnected(config, connName); // Reuse the existing config's endpoint — poll a few times if it lags. let endpointUrl = await getMcpEndpointUrl(config, configName); for (let i = 0; !endpointUrl && i < 5; i++) { await sleep(1000); endpointUrl = await getMcpEndpointUrl(config, configName); } if (!endpointUrl) throw new Error(`MCP endpoint URL not available (connection status: ${status}).`); const key = await mintApiKey(config, configName); const gw = gatewayId(config); await writeMcpEntry(configName, endpointUrl, key, scope, { gatewayId: gw, mcpServerConfigId: `${gw}/mcpserverConfigs/${armSegment(configName)}`, connectionId: `${gw}/connections/${armSegment(connName)}`, apiName, }); return { ok: true, configName, connName, endpointUrl, scope, reauth: true }; } // --------------------------------------------------------------------------- // Installed-state derivation (source of truth = the gateway + CLI config) // --------------------------------------------------------------------------- export async function getInstalledState(config) { const wsPath = s_workspaceRoot ? join(s_workspaceRoot, ".mcp.json") : null; const [configsRes, connectionsRes, profileCfg, workspaceCfg] = await Promise.all([ arm("GET", `${gatewayId(config)}/mcpserverConfigs?api-version=${API_VERSION}`), arm("GET", `${gatewayId(config)}/connections?api-version=${API_VERSION}`), readMcpConfigAt(PROFILE_MCP_PATH), wsPath ? readMcpConfigAt(wsPath) : Promise.resolve({ mcpServers: {} }), ]); const connByName = new Map(); for (const c of connectionsRes.value ?? []) connByName.set(c.name, c); const profileKeys = new Set(Object.keys(profileCfg.mcpServers ?? {})); const workspaceKeys = new Set(Object.keys(workspaceCfg.mcpServers ?? {})); return deriveInstalledState(configsRes.value ?? [], connByName, profileKeys, workspaceKeys, wsPath); } // Pure derivation, split out so it can be unit-tested without live ARM. // A single apiName can have MULTIPLE gateway configs (a portal-side add, a // duplicate Connect, a re-auth that minted a fresh config). Collect every // config per apiName, then pick ONE deterministically instead of letting ARM // list order decide (last-wins) — that overwrite is what stranded a tile on // "Re-authenticate" while a sibling config was actually Connected. export function deriveInstalledState(configs, connByName, profileKeys, workspaceKeys, wsPath) { const candidatesByApi = {}; for (const cfg of configs ?? []) { const connector = cfg.properties?.connectors?.[0]; const apiName = connector?.name; if (!apiName) continue; const connName = connector?.connectionName; const conn = connName ? connByName.get(connName) : null; const connectionStatus = conn?.properties?.statuses?.[0]?.status ?? conn?.properties?.overallStatus ?? "Unknown"; const inWorkspace = workspaceKeys.has(cfg.name); const inProfile = profileKeys.has(cfg.name); (candidatesByApi[apiName] ??= []).push({ installed: true, configName: cfg.name, connectionName: connName || null, connectionStatus, inCli: inProfile || inWorkspace, cliScope: inWorkspace ? "workspace" : (inProfile ? "profile" : null), cliPath: inWorkspace ? wsPath : (inProfile ? PROFILE_MCP_PATH : null), }); } // Prefer the config the local session actually points at, and prefer a // Connected one: inCli && Connected > inCli > Connected > any. Config name // breaks ties so ARM list order cannot change the selected resource. Keeps the flat // one-entry-per-apiName shape the renderer + tests expect. const rank = (e) => (e.inCli ? 2 : 0) + (e.connectionStatus === "Connected" ? 1 : 0); const byApi = {}; for (const [apiName, list] of Object.entries(candidatesByApi)) { list.sort((a, b) => rank(b) - rank(a) || a.configName.localeCompare(b.configName)); const best = list[0]; // Internal-only signal for logging; the renderer ignores unknown fields. byApi[apiName] = list.length > 1 ? { ...best, _configCount: list.length, _candidates: list } : best; } return byApi; } // --------------------------------------------------------------------------- // Browser opener // --------------------------------------------------------------------------- async function launchDetached(command, args) { await new Promise((resolve, reject) => { const child = spawn(command, args, { detached: true, stdio: "ignore" }); child.once("error", reject); child.once("spawn", () => { child.unref(); resolve(); }); }); } export async function openInBrowser(url) { // Only ever hand an http(s) URL to the OS shell — guards against the // consent URL being anything that could be reinterpreted as a command. let safe; try { const u = new URL(url); if (u.protocol !== "http:" && u.protocol !== "https:") return; safe = u.toString(); } catch { return; } const p = platform(); if (p === "win32") { // rundll32 hands the URL to the default protocol handler as a single // literal argv with no shell parsing — avoids cmd.exe `start` metachar // and quoting pitfalls. await launchDetached(await resolveSystemExecutable("rundll32.exe"), ["url.dll,FileProtocolHandler", safe]); } else if (p === "darwin") { await launchDetached(await resolveSystemExecutable("open"), [safe]); } else { await launchDetached(await resolveSystemExecutable("xdg-open"), [safe]); } } // --------------------------------------------------------------------------- // Config file opener // --------------------------------------------------------------------------- // Hand a local file path to the OS so it opens in the user's default handler // for that type (typically their editor for .json). Single literal argv on // every platform — no shell, so a path with spaces or metachars is safe. async function openPath(filePath) { const p = platform(); if (p === "win32") { // FileProtocolHandler also accepts plain file paths and routes them to // the registered default app, same no-shell guarantee as openInBrowser. await launchDetached(await resolveSystemExecutable("rundll32.exe"), ["url.dll,FileProtocolHandler", filePath]); } else if (p === "darwin") { await launchDetached(await resolveSystemExecutable("open"), [filePath]); } else { await launchDetached(await resolveSystemExecutable("xdg-open"), [filePath]); } } // Open the MCP config this canvas writes to (the profile scope — // ~/.copilot/mcp-config.json). Creates an empty, correctly-shaped config if // none exists yet so the editor never opens a missing file. Returns the path // either way so the UI can show where it lives even if the OS open is a no-op. export async function openMcpConfigFile() { const path = PROFILE_MCP_PATH; try { await fs.access(path); } catch { try { await fs.mkdir(dirname(path), { recursive: true }); await fs.writeFile(path, JSON.stringify({ mcpServers: {} }, null, 2) + "\n", { encoding: "utf8", mode: 0o600 }); await fs.chmod(path, 0o600).catch(() => {}); } catch (err) { return { ok: false, path, error: err.message }; } } await openPath(path); return { ok: true, path }; }