Add Azure Connector Namespaces canvas extension 🤖🤖🤖 (#2250)

* Add MCP Connectors (connector-namespaces) canvas extension

A Copilot CLI canvas extension for browsing and adding MCP connectors
from an Azure Connector Namespace into a Copilot session. Sign-in is
dependency-free (OAuth 2.0 auth-code + PKCE via the Azure CLI public
client, loopback redirect); network access is restricted to the public
Azure Resource Manager endpoint. MIT licensed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Update Connector Namespaces canvas extension

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Harden Connector Namespaces canvas extension

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Sanitize connector icon brand colors

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add Connector Namespace playground actions

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf

* Replace sandbox skill with native tool

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf

* Clarify connector disconnect action

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf

* Color disconnect action red

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf

* Add connector extension plugin manifest

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf

* Address connector canvas review feedback

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf

* Remove legacy connector canvas manifest

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf

* Address remaining connector review feedback

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf

* Harden connector review fixes

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf

* Harden connector convergence

Address the latest connector review batch across config persistence, executable trust, reauthentication, JSON-RPC transport, smoke safety, and accessibility.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf

* Use native HTTP connector configs

Persist Connector Namespace MCP servers as direct HTTPS entries with API-key headers, remove the stdio unwrap proxy, and exercise the native Streamable HTTP path in smoke coverage.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf

* Secure persisted connector state

Create the Connector Namespace artifacts directory and saved gateway config with private permissions, and align the reduced-motion regression notes with the static fallback behavior.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf

---------

Co-authored-by: Alex Yang <yangalex@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Alex Yang [MSFT]
2026-07-15 17:29:48 -07:00
committed by GitHub
parent e4a1f57fd9
commit 191309165e
35 changed files with 7658 additions and 0 deletions
@@ -0,0 +1,444 @@
// ARM API client — fetches real connector data with Azure CLI credentials.
import { exec, execFile } from "node:child_process";
import { constants as fsConstants, promises as fs } from "node:fs";
import { homedir, platform } from "node:os";
import { basename, delimiter, dirname, isAbsolute, join, resolve, sep } from "node:path";
import { promisify } from "node:util";
const API_VERSION = "2026-05-01-preview";
const RG_API_VERSION = "2021-04-01";
const MSI_API_VERSION = "2023-01-31";
const SUBS_API_VERSION = "2020-01-01";
const execFileAsync = promisify(execFile);
const execAsync = promisify(exec);
const ARM_RESOURCE = "https://management.azure.com/";
const EXPIRY_SKEW_MS = 5 * 60 * 1000;
const LEGACY_AUTH_CACHE = join(
process.env.COPILOT_HOME || join(homedir(), ".copilot"),
"extensions",
"connector-namespaces",
"artifacts",
"auth-cache.json",
);
let s_auth = null; // { token, expiresAt }
let s_authInFlight = null;
let s_legacyAuthCacheRemoved = false;
export function parseAzureCliToken(stdout) {
let data;
try {
data = JSON.parse(stdout);
} catch {
throw new Error("Azure CLI returned invalid token JSON.");
}
const token = data?.accessToken;
const epochSeconds = Number(data?.expires_on);
const expiresAt = Number.isFinite(epochSeconds) && epochSeconds > 0
? epochSeconds * 1000
: Date.parse(data?.expiresOn);
if (typeof token !== "string" || token.length === 0 || !Number.isFinite(expiresAt)) {
throw new Error("Azure CLI returned an incomplete ARM token.");
}
return { token, expiresAt };
}
async function removeLegacyAuthCache() {
if (s_legacyAuthCacheRemoved) return;
try {
await fs.unlink(LEGACY_AUTH_CACHE);
} catch (error) {
if (error?.code !== "ENOENT") {
throw new Error(`Could not remove the legacy connector credential cache at ${LEGACY_AUTH_CACHE}: ${error.message}`);
}
}
s_legacyAuthCacheRemoved = true;
}
function windowsSystemExecutable(name) {
const systemRoot = process.env.SystemRoot;
if (systemRoot && isAbsolute(systemRoot)) return join(systemRoot, "System32", name);
throw new Error(`Could not resolve the Windows system executable ${name}.`);
}
async function trustedExecutablePath(path, expectedName, workspaceRoot = process.cwd()) {
if (!isAbsolute(path) || /["\r\n]/.test(path)) return null;
let candidate;
let workspace;
try {
[candidate, workspace] = await Promise.all([
fs.realpath(path),
fs.realpath(workspaceRoot),
]);
if (!(await fs.stat(candidate)).isFile()) return null;
if (platform() !== "win32") await fs.access(candidate, fsConstants.X_OK);
} catch {
return null;
}
const insensitive = platform() === "win32";
const normalize = (value) => insensitive ? value.toLowerCase() : value;
const normalizedCandidate = normalize(resolve(candidate));
const normalizedWorkspace = normalize(resolve(workspace));
const workspacePrefix = normalizedWorkspace.endsWith(sep)
? normalizedWorkspace
: `${normalizedWorkspace}${sep}`;
if (
normalize(basename(candidate)) !== normalize(expectedName) ||
normalizedCandidate === normalizedWorkspace ||
normalizedCandidate.startsWith(workspacePrefix)
) return null;
return candidate;
}
async function resolveWindowsAzureCli() {
const trustedCwd = homedir();
const { stdout } = await execFileAsync(
windowsSystemExecutable("where.exe"),
["az.cmd"],
{ cwd: trustedCwd, encoding: "utf8", windowsHide: true, timeout: 10_000, maxBuffer: 64 * 1024 },
);
for (const path of stdout.split(/\r?\n/).map((line) => line.trim())) {
if (/[%]/.test(path)) continue;
const candidate = await trustedExecutablePath(path, "az.cmd");
if (candidate) return candidate;
}
throw new Error("Azure CLI was not found outside the current workspace.");
}
export async function resolvePosixAzureCli(pathValue = process.env.PATH || "", workspaceRoot = process.cwd()) {
for (const directory of pathValue.split(delimiter)) {
const candidate = await trustedExecutablePath(resolve(directory || workspaceRoot, "az"), "az", workspaceRoot);
if (candidate) return candidate;
}
throw new Error("Azure CLI was not found outside the current workspace.");
}
export async function resolveSystemExecutable(name, workspaceRoot = process.cwd()) {
const candidates = platform() === "win32"
? [windowsSystemExecutable(name)]
: [join("/usr/bin", name), join("/bin", name), join("/usr/local/bin", name)];
for (const path of candidates) {
const candidate = await trustedExecutablePath(path, name, workspaceRoot);
if (candidate) return candidate;
}
throw new Error(`Could not resolve the trusted system executable ${name}.`);
}
async function acquireToken() {
await removeLegacyAuthCache();
try {
const windows = platform() === "win32";
const azureCli = windows ? await resolveWindowsAzureCli() : await resolvePosixAzureCli();
const options = { cwd: homedir(), encoding: "utf8", windowsHide: true, timeout: 60_000, maxBuffer: 1024 * 1024 };
const { stdout } = windows
? await execAsync(
`"${azureCli}" account get-access-token --resource https://management.azure.com/ --output json --only-show-errors`,
{ ...options, shell: windowsSystemExecutable("cmd.exe") },
)
: await execFileAsync(
azureCli,
["account", "get-access-token", "--resource", ARM_RESOURCE, "--output", "json", "--only-show-errors"],
options,
);
s_auth = parseAzureCliToken(stdout);
return s_auth.token;
} catch (error) {
const detail = String(error?.stderr || error?.message || "").trim();
throw new Error(
`Azure CLI authentication failed. Install Azure CLI and run "az login" before opening the canvas.${detail ? ` ${detail}` : ""}`,
);
}
}
export async function getToken() {
if (s_auth && s_auth.expiresAt - EXPIRY_SKEW_MS > Date.now()) return s_auth.token;
if (s_authInFlight) return s_authInFlight;
s_authInFlight = acquireToken().finally(() => {
s_authInFlight = null;
});
return s_authInFlight;
}
/**
* List all enabled Azure subscriptions the user has access to.
*/
// The set of enabled subscriptions is stable for a session, so cache it — the
// first /setup pays the ARM round-trip once and every "Change namespace"
// afterwards serves from memory.
let s_subsCache = null; // { subs, expiresAt }
const SUBS_TTL_MS = 30 * 60 * 1000;
export async function listSubscriptions() {
const now = Date.now();
if (s_subsCache && s_subsCache.expiresAt > now) return s_subsCache.subs;
const token = await getToken();
const url = `https://management.azure.com/subscriptions?api-version=${SUBS_API_VERSION}`;
const raw = await paginateAll(url, token);
const subs = raw
.filter((s) => s.state === "Enabled")
.map((s) => ({ id: s.subscriptionId, name: s.displayName, tenantId: s.tenantId, state: s.state }));
s_subsCache = { subs, expiresAt: now + SUBS_TTL_MS };
return subs;
}
// ARM resource identifiers are a restricted charset (letters, digits and a few
// punctuation chars). Validating each path segment against this allowlist before
// it enters a URL rejects anything containing "/", "?", "#", "@" or ":" — the
// characters that could otherwise alter the request path or redirect the host —
// and acts as a taint barrier so config/file-derived names cannot reach fetch
// unvalidated.
const ARM_SEGMENT = /^[A-Za-z0-9._()-]{1,256}$/;
export function armSegment(value) {
const s = String(value);
if (s === "." || s === ".." || !ARM_SEGMENT.test(s)) {
throw new Error(`Invalid ARM resource identifier: ${s}`);
}
return s;
}
function buildBaseUrl(subscriptionId, resourceGroup, gatewayName) {
return `https://management.azure.com/subscriptions/${armSegment(subscriptionId)}/resourceGroups/${armSegment(resourceGroup)}/providers/Microsoft.Web/connectorGateways/${armSegment(gatewayName)}`;
}
// Hard host allowlist: every request this client makes targets ARM and only
// ARM. The trailing slash matters — it blocks suffix/userinfo bypasses such as
// "https://management.azure.com.evil.com/" and "https://management.azure.com@evil.com/",
// neither of which starts with this exact prefix. This guards the paginated
// nextLink (a server-supplied value) which does not pass through armSegment.
const ARM_BASE = "https://management.azure.com/";
// Returns the URL only if it targets ARM, otherwise throws. Used by callers
// (e.g. install.mjs) that build ARM URLs before handing them here.
export function assertArmHost(rawUrl) {
const url = String(rawUrl);
if (!url.startsWith(ARM_BASE)) {
throw new Error(`Refusing to call non-ARM URL: ${url}`);
}
return url;
}
async function armFetch(url, token) {
// Guard the exact value handed to fetch so a tainted path segment or a
// server-supplied nextLink can never redirect the call off ARM.
if (!url.startsWith(ARM_BASE)) {
throw new Error(`Refusing to call non-ARM URL: ${url}`);
}
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
});
if (!res.ok) {
const body = await res.text();
throw new Error(`ARM ${res.status}: ${body.slice(0, 300)}`);
}
return res.json();
}
// ARM normally returns distinct nextLink URLs that terminate, but a buggy or
// hostile endpoint could return a repeating/self-referential nextLink. Guard
// against an unbounded loop with a seen-set and a hard page cap.
const MAX_PAGES = 1000;
async function paginateAll(url, token) {
const results = [];
const seen = new Set();
let nextUrl = url;
let pages = 0;
while (nextUrl) {
if (seen.has(nextUrl) || pages >= MAX_PAGES) break;
seen.add(nextUrl);
pages++;
const data = await armFetch(nextUrl, token);
if (data.value) results.push(...data.value);
nextUrl = data.nextLink || null;
}
return results;
}
/**
* List connector gateways in a subscription.
* Uses $top=10 and stops after the first page for speed.
* Pass fetchAll=true to paginate through everything.
*/
export async function listConnectorGateways(subscriptionId, { fetchAll = false } = {}) {
const token = await getToken();
const url = `https://management.azure.com/subscriptions/${armSegment(subscriptionId)}/providers/Microsoft.Web/connectorGateways?api-version=${API_VERSION}&$top=10`;
if (fetchAll) return { items: await paginateAll(url, token), hasMore: false };
// First page only — much faster
const data = await armFetch(url, token);
const items = data.value || [];
return { items, hasMore: !!data.nextLink };
}
/**
* List managed APIs (traditional connectors)
*/
export async function listManagedApis(subscriptionId, resourceGroup, gatewayName) {
const token = await getToken();
const url = `${buildBaseUrl(subscriptionId, resourceGroup, gatewayName)}/managedApis?api-version=${API_VERSION}`;
return paginateAll(url, token);
}
/**
* List managed hosted MCP servers
*/
export async function listManagedHostedMcpServers(subscriptionId, resourceGroup, gatewayName) {
const token = await getToken();
const url = `${buildBaseUrl(subscriptionId, resourceGroup, gatewayName)}/managedHostedMcpServers?api-version=${API_VERSION}`;
return paginateAll(url, token);
}
/**
* List managed MCP operations
*/
export async function listManagedMcpOperations(subscriptionId, resourceGroup, gatewayName) {
const token = await getToken();
const url = `${buildBaseUrl(subscriptionId, resourceGroup, gatewayName)}/managedMcpOperations?api-version=${API_VERSION}`;
return paginateAll(url, token);
}
// ---------------------------------------------------------------------------
// Create connector namespace (provisioning flow)
// ---------------------------------------------------------------------------
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Write helper (PUT/PATCH/DELETE) that mirrors armFetch's host guard but keeps
// the parsed error body so callers can surface ARM's message verbatim.
async function armWrite(method, url, body, extraHeaders = {}) {
if (!url.startsWith(ARM_BASE)) {
throw new Error(`Refusing to call non-ARM URL: ${url}`);
}
const token = await getToken();
const headers = { Authorization: `Bearer ${token}` };
Object.assign(headers, extraHeaders);
if (body !== undefined) headers["Content-Type"] = "application/json";
const res = await fetch(url, {
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) {
const msg = parsed?.error?.message ?? parsed?.message ?? text ?? `HTTP ${res.status}`;
const err = new Error(`ARM ${method} ${res.status}: ${String(msg).slice(0, 300)}`);
err.status = res.status;
throw err;
}
return parsed;
}
/**
* List resource groups in a subscription (sorted by name).
*/
export async function listResourceGroups(subscriptionId) {
const token = await getToken();
const url = `https://management.azure.com/subscriptions/${armSegment(subscriptionId)}/resourcegroups?api-version=${RG_API_VERSION}`;
const items = await paginateAll(url, token);
return items
.map((rg) => ({ name: rg.name, location: rg.location }))
.sort((a, b) => a.name.localeCompare(b.name));
}
/**
* Create a resource group without updating an existing group on a name race.
*/
export async function createResourceGroup(subscriptionId, name, location) {
const url = `https://management.azure.com/subscriptions/${armSegment(subscriptionId)}/resourcegroups/${armSegment(name)}?api-version=${RG_API_VERSION}`;
return armWrite("PUT", url, { location }, { "If-None-Match": "*" });
}
/**
* List user-assigned managed identities across a subscription (sorted by name).
*/
export async function listUserAssignedIdentities(subscriptionId) {
const token = await getToken();
const url = `https://management.azure.com/subscriptions/${armSegment(subscriptionId)}/providers/Microsoft.ManagedIdentity/userAssignedIdentities?api-version=${MSI_API_VERSION}`;
const items = await paginateAll(url, token);
return items
.map((id) => {
const parts = String(id.id).split("/");
const rgIdx = parts.findIndex((p) => p.toLowerCase() === "resourcegroups");
return {
id: id.id,
name: id.name,
resourceGroup: rgIdx >= 0 ? parts[rgIdx + 1] || "" : "",
location: id.location || "",
};
})
.sort((a, b) => a.name.localeCompare(b.name));
}
/**
* Check whether a connector namespace name is free in the given resource group.
* Returns true when available (ARM 404), false when taken (200). Uses fetch
* directly so the 404 isn't thrown the way armFetch would.
*/
export async function checkConnectorGatewayNameAvailable(subscriptionId, resourceGroup, gatewayName) {
const token = await getToken();
const url = `${buildBaseUrl(subscriptionId, resourceGroup, gatewayName)}?api-version=${API_VERSION}`;
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (res.status === 404) return true;
if (res.ok) return false;
const body = await res.text();
throw new Error(`ARM ${res.status}: ${body.slice(0, 200)}`);
}
// ARM `identity` block — mirrors the portal's buildIdentityPayload so the PUT
// body is always explicit ({ type: "None" } when nothing is configured).
export function buildGatewayIdentity(enableSystem, userAssignedIds = []) {
const hasUser = userAssignedIds.length > 0;
const type = enableSystem && hasUser
? "SystemAssigned,UserAssigned"
: enableSystem
? "SystemAssigned"
: hasUser
? "UserAssigned"
: "None";
const identity = { type };
if (hasUser) {
identity.userAssignedIdentities = Object.fromEntries(userAssignedIds.map((id) => [id, {}]));
}
return identity;
}
export async function waitForProvisioning(initialResult, gatewayName, fetchLatest, {
maxPolls = 60,
delay = () => sleep(3000),
} = {}) {
let result = initialResult;
let state;
for (let poll = 0; poll <= maxPolls; poll++) {
state = result?.properties?.provisioningState;
if (state === "Succeeded") return result;
if (state === "Failed" || state === "Canceled") {
throw new Error(`Provisioning ${state} for "${gatewayName}".`);
}
if (poll === maxPolls) break;
await delay();
result = await fetchLatest();
}
throw new Error(`Provisioning timed out for "${gatewayName}" (last state: ${state ?? "unknown"}).`);
}
/**
* Create a connector namespace and poll until the
* provisioningState reaches a terminal value. Throws on Failed/Canceled.
* Returns the final resource object.
*/
export async function createConnectorGateway(subscriptionId, resourceGroup, gatewayName, { location, identity }) {
const token = await getToken();
const url = `${buildBaseUrl(subscriptionId, resourceGroup, gatewayName)}?api-version=${API_VERSION}`;
const body = { location, properties: {}, identity };
const result = await armWrite("PUT", url, body, { "If-None-Match": "*" });
// ~3 min ceiling (60 * 3s). A 202 may have no body, so every state other
// than explicit Succeeded enters the polling path.
return waitForProvisioning(result, gatewayName, () => armFetch(url, token));
}