mirror of
https://github.com/github/awesome-copilot.git
synced 2026-07-16 10:53:25 +00:00
1008 lines
43 KiB
JavaScript
1008 lines
43 KiB
JavaScript
// 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 -> <repo>/.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<connector metadata>
|
|
|
|
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 };
|
|
}
|