diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json
index e91a7607..eebc5b16 100644
--- a/.github/plugin/marketplace.json
+++ b/.github/plugin/marketplace.json
@@ -241,6 +241,12 @@
"description": "A visual orb that users can ask the agent to recolor while showing a live activity log in the canvas.",
"version": "1.0.0"
},
+ {
+ "name": "connector-namespaces",
+ "source": "extensions/connector-namespaces",
+ "description": "Browse, connect, and open MCP connectors from an Azure Connector Namespace.",
+ "version": "1.1.0"
+ },
{
"name": "context-engineering",
"source": "plugins/context-engineering",
diff --git a/extensions/connector-namespaces/.github/plugin/plugin.json b/extensions/connector-namespaces/.github/plugin/plugin.json
new file mode 100644
index 00000000..18b8c116
--- /dev/null
+++ b/extensions/connector-namespaces/.github/plugin/plugin.json
@@ -0,0 +1,19 @@
+{
+ "name": "connector-namespaces",
+ "description": "Browse, connect, and open MCP connectors from an Azure Connector Namespace.",
+ "version": "1.1.0",
+ "author": {
+ "name": "Alex Yang",
+ "url": "https://github.com/alexyaang"
+ },
+ "keywords": [
+ "azure",
+ "connector-namespace",
+ "mcp",
+ "mcp-connectors",
+ "model-context-protocol",
+ "tool-discovery"
+ ],
+ "logo": "assets/preview.png",
+ "extensions": "."
+}
diff --git a/extensions/connector-namespaces/LICENSE b/extensions/connector-namespaces/LICENSE
new file mode 100644
index 00000000..22aed37e
--- /dev/null
+++ b/extensions/connector-namespaces/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) Microsoft Corporation.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/extensions/connector-namespaces/README.md b/extensions/connector-namespaces/README.md
new file mode 100644
index 00000000..f7346247
--- /dev/null
+++ b/extensions/connector-namespaces/README.md
@@ -0,0 +1,85 @@
+# MCP Connectors — Copilot CLI Canvas Extension
+
+A GitHub Copilot CLI **canvas extension** that lets you browse and add MCP
+connectors from an Azure **Connector Namespace** directly inside a Copilot CLI
+session. Search by name or category, sign in to a connector, then restart the
+session to make its tools available to the agent.
+
+> The canvas talks to public Azure Resource Manager (`management.azure.com`)
+> using the signed-in Azure CLI account. The extension does not register its own
+> Entra application or persist Azure credentials.
+
+## Prerequisites
+
+- **GitHub Copilot CLI** (the host that loads canvas extensions).
+- **Azure CLI**, signed in with `az login`. The extension asks Azure CLI for a
+ short-lived ARM access token and refreshes it through the same broker.
+- **An Azure subscription with a Connector Namespace** — resource type
+ `Microsoft.Web/connectorGateways` (API version `2026-05-01-preview`). This is
+ a preview resource provider; you must have access to it for the catalog to
+ load. Without it the extension installs fine but has nothing to show.
+
+## Install
+
+Install it from the public Awesome Copilot repository:
+
+```
+install_extension https://github.com/github/awesome-copilot/tree/main/extensions/connector-namespaces
+```
+
+For a reproducible install, swap `main` for a reviewed commit SHA from this
+repository.
+
+The destination **scope** is chosen at install time:
+
+- **user** (default) — installs globally for you at
+ `$COPILOT_HOME/extensions/connector-namespaces/`. The usual choice for a
+ personal tool.
+- **project** — installs into the current repo.
+- **session** — scoped to a single CLI session.
+
+## Usage
+
+1. Open the **MCP Connectors** canvas from Copilot CLI.
+2. The canvas loads subscriptions from your signed-in Azure CLI account. Pick an
+ Azure **subscription** and a **Connector Namespace**. The choice is saved for
+ future sessions (change it any time via **Change namespace**).
+3. Browse or filter the connector catalog, then **Connect**. A browser tab
+ opens for Microsoft sign-in; complete it and the canvas updates on its own.
+4. Connected connectors move into **My MCPs**. Use **Sandbox** on a tile to open
+ that server directly in the namespace MCP playground.
+5. Restart the Copilot CLI session so the agent can load the connected tools.
+
+The extension registers the native `connector_namespaces_open_playground` tool,
+so GitHub Copilot can open a named connector from **My MCPs** without installing
+an additional Agent Skill.
+
+## How it works
+
+- `extension.mjs` — entry point; declares the canvas, `open_sandbox` action, and
+ native `connector_namespaces_open_playground` tool.
+- `server.mjs` — a loopback HTTP server (bound to `127.0.0.1` only) that serves
+ the canvas UI and the JSON/OAuth endpoints the iframe calls.
+- `armClient.mjs` — thin ARM client (token brokered by Azure CLI, public ARM
+ base only, SSRF-guarded path segments).
+- `catalog.mjs` — fetches and curates the connector list for a namespace.
+- `install.mjs` — the connect/install pipeline (managed-API connection, consent,
+ rollback on cancel, and native HTTPS MCP config registration).
+- `renderer.mjs` — all canvas HTML/CSS/client JS.
+- `sandbox.mjs` — builds namespace playground links and resolves named My MCPs.
+- `state.mjs` — saved namespace and connector state.
+
+## Privacy & security
+
+- ARM tokens come from `az account get-access-token`, stay in process memory,
+ and are never logged or written by the extension. Azure CLI owns sign-in and
+ credential storage.
+- All servers bind to loopback (`127.0.0.1`) and are never exposed externally.
+- ARM requests go only to `https://management.azure.com/`; path segments are
+ validated to prevent SSRF-style host smuggling.
+- The minted gateway API key is stored in the selected Copilot MCP config and
+ sent to its validated HTTPS endpoint as the `X-API-Key` header.
+
+## License
+
+[MIT](./LICENSE) © Microsoft Corporation.
diff --git a/extensions/connector-namespaces/armClient.mjs b/extensions/connector-namespaces/armClient.mjs
new file mode 100644
index 00000000..dc0f9fc4
--- /dev/null
+++ b/extensions/connector-namespaces/armClient.mjs
@@ -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));
+}
diff --git a/extensions/connector-namespaces/assets/preview-connected.png b/extensions/connector-namespaces/assets/preview-connected.png
new file mode 100644
index 00000000..4050d878
Binary files /dev/null and b/extensions/connector-namespaces/assets/preview-connected.png differ
diff --git a/extensions/connector-namespaces/assets/preview.png b/extensions/connector-namespaces/assets/preview.png
new file mode 100644
index 00000000..259e2794
Binary files /dev/null and b/extensions/connector-namespaces/assets/preview.png differ
diff --git a/extensions/connector-namespaces/catalog.mjs b/extensions/connector-namespaces/catalog.mjs
new file mode 100644
index 00000000..79f4165c
--- /dev/null
+++ b/extensions/connector-namespaces/catalog.mjs
@@ -0,0 +1,70 @@
+// Catalog — fetches MCP connectors from the gateway.
+//
+// The gateway exposes ~1600 managed APIs (the full Logic Apps connector
+// catalog). MCP servers are a small subset (~43) and there is no `kind` or
+// capability flag that distinguishes them. The only reliable signal is the
+// string "mcp" appearing in the API's name OR its display name — and those are
+// genuinely independent signals: `workiqsharepoint` has no "mcp" in its name
+// (display name "Work IQ SharePoint MCP"), while `hginsightsmcp` has "mcp" in
+// its name but a display name of "HG Insights Connect". Matching either keeps
+// the full set without an allowlist that has to be hand-maintained.
+
+import { listManagedApis } from "./armClient.mjs";
+import { CATEGORY } from "./categories.mjs";
+
+function isMcpServer(api) {
+ const name = api.name || "";
+ const displayName = api.properties?.generalInformation?.displayName || "";
+ return /mcp/i.test(name) || /mcp/i.test(displayName);
+}
+
+// Microsoft first-party servers (a365*/d365*/workiq* names, or a Microsoft-
+// branded display name) group under "Microsoft"; everything else is a partner
+// server. Derived rather than hardcoded so new servers categorize themselves.
+function categoryFor(name, displayName) {
+ const n = (name || "").toLowerCase();
+ const d = (displayName || "").toLowerCase();
+ const isMicrosoft =
+ /^(a365|d365|workiq)/.test(n) ||
+ d.startsWith("microsoft") ||
+ d.startsWith("work iq") ||
+ d.startsWith("dynamics 365");
+ return isMicrosoft ? CATEGORY.microsoft : CATEGORY.partner;
+}
+
+let cachedCatalog = null;
+let cacheKey = null;
+
+export function invalidateCache() {
+ cachedCatalog = null;
+ cacheKey = null;
+}
+
+export async function fetchCatalog(subscriptionId, resourceGroup, gatewayName) {
+ const key = `${subscriptionId}/${resourceGroup}/${gatewayName}`;
+ if (cachedCatalog && cacheKey === key) return cachedCatalog;
+
+ const apis = await listManagedApis(subscriptionId, resourceGroup, gatewayName);
+
+ const catalog = apis
+ .filter(isMcpServer)
+ .map((a) => {
+ const props = a.properties || {};
+ const general = props.generalInformation || {};
+ const metadata = props.metadata || {};
+ const displayName = general.displayName || a.name;
+ return {
+ id: a.name,
+ apiName: a.name,
+ displayName,
+ description: general.description || "",
+ iconUri: general.iconUri || "",
+ brandColor: metadata.brandColor || "",
+ category: categoryFor(a.name, displayName),
+ };
+ });
+
+ cachedCatalog = catalog;
+ cacheKey = key;
+ return catalog;
+}
diff --git a/extensions/connector-namespaces/categories.mjs b/extensions/connector-namespaces/categories.mjs
new file mode 100644
index 00000000..57cd9592
--- /dev/null
+++ b/extensions/connector-namespaces/categories.mjs
@@ -0,0 +1,15 @@
+// Catalog category values.
+//
+// `category` is a routing key, not display text: the renderer partitions catalog
+// items into the Microsoft vs Partners sections by comparing against these exact
+// values (see renderCatalogHtml in renderer.mjs). Kept as a frozen enum here,
+// rather than free-form strings scattered across the producer, renderer, and
+// tests, so the routing contract lives in one place.
+//
+// Zero-dependency on purpose: renderer.mjs imports this, and renderer.test.mjs
+// loads renderer.mjs as a pure string-rendering gate. Sourcing the enum from
+// catalog.mjs instead would drag armClient.mjs (the ARM SDK) into that gate.
+export const CATEGORY = Object.freeze({
+ microsoft: "Microsoft",
+ partner: "Partners",
+});
diff --git a/extensions/connector-namespaces/copilot-extension.json b/extensions/connector-namespaces/copilot-extension.json
new file mode 100644
index 00000000..3949dbe4
--- /dev/null
+++ b/extensions/connector-namespaces/copilot-extension.json
@@ -0,0 +1,4 @@
+{
+ "name": "connector-namespaces",
+ "version": 1
+}
diff --git a/extensions/connector-namespaces/createPage.mjs b/extensions/connector-namespaces/createPage.mjs
new file mode 100644
index 00000000..3eb86659
--- /dev/null
+++ b/extensions/connector-namespaces/createPage.mjs
@@ -0,0 +1,403 @@
+// Renderer for the "Create connector namespace" wizard page. Mirrors the
+// portal's CreateConnectorGatewayPage: subscription -> resource group
+// (existing or new) -> region -> name (live availability) -> managed identity
+// (system + user-assigned) -> real ARM provisioning.
+
+import { baseStyles, brandMark } from "./renderer.mjs";
+
+function esc(s) {
+ return String(s || "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
+}
+
+// Connector namespace regions — kept in sync with the portal's
+// CONNECTOR_NAMESPACE_REGIONS list (constants.ts).
+const REGIONS = [
+ ["australiaeast", "Australia East"], ["brazilsouth", "Brazil South"],
+ ["canadacentral", "Canada Central"], ["canadaeast", "Canada East"],
+ ["centralindia", "Central India"], ["centralus", "Central US"],
+ ["eastasia", "East Asia"], ["eastus", "East US"], ["eastus2", "East US 2"],
+ ["francecentral", "France Central"], ["germanywestcentral", "Germany West Central"],
+ ["italynorth", "Italy North"], ["japaneast", "Japan East"],
+ ["koreacentral", "Korea Central"], ["northcentralus", "North Central US"],
+ ["northeurope", "North Europe"], ["norwayeast", "Norway East"],
+ ["polandcentral", "Poland Central"], ["southafricanorth", "South Africa North"],
+ ["southcentralus", "South Central US"], ["southindia", "South India"],
+ ["southeastasia", "Southeast Asia"], ["spaincentral", "Spain Central"],
+ ["swedencentral", "Sweden Central"], ["switzerlandnorth", "Switzerland North"],
+ ["uaenorth", "UAE North"], ["uksouth", "UK South"],
+ ["westcentralus", "West Central US"], ["westus2", "West US 2"],
+ ["westus3", "West US 3"],
+];
+
+const DEFAULT_REGION = "eastus";
+
+export function renderCreateNamespaceHtml(subscriptions, preselectedSub = "", capabilityToken = "") {
+ const subOptions = subscriptions.map((s) =>
+ `${esc(s.name)} (${esc(s.id.slice(0, 8))}\u2026) `
+ ).join("");
+
+ const regionOptions = REGIONS.map(([v, l]) =>
+ `${l} `
+ ).join("");
+
+ return `
+
+Create Connector Namespace ${baseStyles()}
+
+\u2190 Back to namespaces
+
+
${brandMark(28, "create")}Create connector namespace
+
Provisions a real Azure connector namespace (Microsoft.Web/connectorGateways) in your subscription.
+
+
+
+ Subscription
+
+ -- Select subscription --
+ ${subOptions}
+
+
+
+
+
Resource group
+
+ Use existing
+ Create new
+
+
-- Select subscription first --
+
+
Pick the resource group the namespace will live in.
+
+
+
+ Region
+ ${regionOptions}
+
+
+
+
+
+
Managed identity
+
+
+ System-assigned
+
+
+
+ User-assigned
+
+
Select a subscription to list identities.
+
+
+
+ Cancel
+ Create
+
+
+
+
+`;
+}
diff --git a/extensions/connector-namespaces/extension.mjs b/extensions/connector-namespaces/extension.mjs
new file mode 100644
index 00000000..fe6f502e
--- /dev/null
+++ b/extensions/connector-namespaces/extension.mjs
@@ -0,0 +1,101 @@
+// Canvas extension entry point — MCP Connectors browser.
+
+import { joinSession, createCanvas } from "@github/copilot-sdk/extension";
+import { getServerConfig, startServer, stopServer } from "./server.mjs";
+import { getSavedConfig, loadSavedConfig, saveConfig } from "./state.mjs";
+import { fetchCatalog } from "./catalog.mjs";
+import { getInstalledState, openInBrowser, setWorkspaceRoot } from "./install.mjs";
+import { buildSandboxUrl, resolveSandboxConnector } from "./sandbox.mjs";
+
+// Load any previously saved connector namespace config on startup
+loadSavedConfig();
+
+async function openPlayground(server, instanceId) {
+ const config = instanceId ? getServerConfig(instanceId) : getSavedConfig();
+ if (!config) return { opened: false, reason: "no_namespace_configured" };
+ const catalog = await fetchCatalog(config.subscriptionId, config.resourceGroup, config.gatewayName);
+ const installedState = await getInstalledState(config);
+ const resolved = resolveSandboxConnector(catalog, installedState, server);
+ if (!resolved.connector) return { opened: false, ...resolved };
+ const url = buildSandboxUrl(config, resolved.connector.id);
+ await openInBrowser(url);
+ return { opened: true, server: resolved.connector, url };
+}
+
+const session = await joinSession({
+ tools: [
+ {
+ name: "connector_namespaces_open_playground",
+ description: "Open a named connector from My MCPs in the Azure Connector Namespace playground.",
+ parameters: {
+ type: "object",
+ properties: {
+ server: {
+ type: "string",
+ description: "Connector display name or server ID from My MCPs",
+ },
+ },
+ required: ["server"],
+ },
+ handler: async ({ server }) => JSON.stringify(await openPlayground(server)),
+ },
+ ],
+ canvases: [
+ createCanvas({
+ id: "connector-namespaces",
+ displayName: "MCP Connectors",
+ description: "Browse, connect, and open MCP connectors in the Azure Connector Namespace Sandbox.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ subscriptionId: { type: "string", description: "Azure subscription ID (optional \u2014 if omitted, uses saved config or shows picker)" },
+ resourceGroup: { type: "string", description: "Resource group name" },
+ gatewayName: { type: "string", description: "Connector namespace name" },
+ },
+ },
+ actions: [
+ {
+ name: "open_sandbox",
+ description: "Open a named connector from My MCPs in the Azure Connector Namespace Sandbox",
+ inputSchema: {
+ type: "object",
+ properties: {
+ server: {
+ type: "string",
+ description: "Connector display name or server ID from My MCPs",
+ },
+ },
+ required: ["server"],
+ },
+ handler: async (ctx) => openPlayground(ctx.input.server, ctx.instanceId),
+ },
+ ],
+ open: async (ctx) => {
+ let config;
+ // If explicit input provided, use it and save for future
+ if (ctx.input && ctx.input.subscriptionId && ctx.input.resourceGroup && ctx.input.gatewayName) {
+ config = {
+ subscriptionId: ctx.input.subscriptionId,
+ resourceGroup: ctx.input.resourceGroup,
+ gatewayName: ctx.input.gatewayName,
+ };
+ saveConfig(config);
+ }
+ // A saved config seeds a new panel only. Rehydrating an existing
+ // panel keeps its active namespace even if another panel changed
+ // the persisted default.
+ const entry = await startServer(
+ ctx.instanceId,
+ config ? { config } : { defaultConfig: getSavedConfig() },
+ );
+ return { title: "MCP Connectors", url: entry.url };
+ },
+ onClose: async (ctx) => {
+ await stopServer(ctx.instanceId);
+ },
+ }),
+ ],
+});
+
+// Tell the install pipeline where the workspace .mcp.json lives (if any).
+setWorkspaceRoot(session.workspacePath);
diff --git a/extensions/connector-namespaces/install.mjs b/extensions/connector-namespaces/install.mjs
new file mode 100644
index 00000000..74264d6f
--- /dev/null
+++ b/extensions/connector-namespaces/install.mjs
@@ -0,0 +1,1007 @@
+// 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 };
+}
diff --git a/extensions/connector-namespaces/install.reauth.test.mjs b/extensions/connector-namespaces/install.reauth.test.mjs
new file mode 100644
index 00000000..50da0470
--- /dev/null
+++ b/extensions/connector-namespaces/install.reauth.test.mjs
@@ -0,0 +1,558 @@
+// Phase 2 regression: Re-authenticate must re-consent the EXISTING connection and
+// mint NO new resources.
+//
+// Before the fix, the "Re-authenticate" button ran the full install path, so it
+// created a fresh connection + a fresh mcpserverConfig on every click. A teammate
+// saw a new Dynamics config appear on the namespace each time they re-authed, while
+// the panel stayed stuck on "Re-authenticate". This test stubs ARM and proves
+// reauthConnector adopts the local session's connection and issues ZERO PUTs.
+//
+// Run: node --test extensions/connector-namespaces/install.reauth.test.mjs
+
+import { test, after } from "node:test";
+import assert from "node:assert/strict";
+import { spawn } from "node:child_process";
+import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
+import { delimiter, join } from "node:path";
+import { tmpdir } from "node:os";
+
+// Isolate COPILOT_HOME before importing install.mjs because its paths are bound at
+// module-eval time. Put a fake Azure CLI on PATH so getToken() stays offline, and
+// seed a profile config so the local entry reads as inCli.
+const TMP = mkdtempSync(join(tmpdir(), "cn-reauth-"));
+process.env.COPILOT_HOME = TMP;
+process.env.USERPROFILE = TMP; // homedir() on Windows
+process.env.HOME = TMP; // homedir() on posix
+
+const binDir = join(TMP, "bin");
+mkdirSync(binDir, { recursive: true });
+const tokenJson = JSON.stringify({ accessToken: "fake-token", expires_on: Math.floor(Date.now() / 1000) + 3600 });
+writeFileSync(join(binDir, "az"), `#!/usr/bin/env node\nprocess.stdout.write(${JSON.stringify(tokenJson)});\n`);
+chmodSync(join(binDir, "az"), 0o755);
+writeFileSync(join(binDir, "az.cmd"), `@echo ${tokenJson}\r\n`);
+process.env.PATH = `${binDir}${delimiter}${process.env.PATH || ""}`;
+
+const legacyAuthCache = join(TMP, "extensions", "connector-namespaces", "artifacts", "auth-cache.json");
+mkdirSync(join(TMP, "extensions", "connector-namespaces", "artifacts"), { recursive: true });
+writeFileSync(legacyAuthCache, JSON.stringify({ accessToken: "legacy", refreshToken: "legacy" }));
+
+writeFileSync(
+ join(TMP, "mcp-config.json"),
+ JSON.stringify({ mcpServers: { "docusign-bbb": { type: "http", url: "https://example/mcp" } } }),
+);
+
+// Dynamic import AFTER the env is set. A static top-level import would be hoisted
+// and evaluate install.mjs (binding the paths to the real home) before the env
+// assignments run.
+const {
+ deleteConnection,
+ finishInstall,
+ getInstalledState,
+ installConnector,
+ loadConnectorMeta,
+ reauthConnector,
+ removeMcpEntry,
+ uninstallConnector,
+} = await import("./install.mjs");
+
+after(() => {
+ try {
+ rmSync(TMP, { recursive: true, force: true });
+ } catch {
+ /* best-effort temp cleanup */
+ }
+});
+
+test("re-authenticate re-consents the existing connection and mints no new resources", async (t) => {
+ const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "gw1" };
+
+ // Two configs for one apiName — the bug scenario. configA is a portal-added
+ // sibling that is NOT in the local CLI; configB is the one the local session
+ // points at. Both connections are Connected, so selection turns on inCli:
+ // deriveInstalledState must pick configB, and the re-consent must target conn-b.
+ const configA = { name: "docusign-aaa", properties: { connectors: [{ name: "docusign", connectionName: "conn-a" }] } };
+ const configB = { name: "docusign-bbb", properties: { connectors: [{ name: "docusign", connectionName: "conn-b" }] } };
+ const connA = { name: "conn-a", properties: { statuses: [{ status: "Connected" }] } };
+ const connB = { name: "conn-b", properties: { statuses: [{ status: "Connected" }] } };
+
+ const calls = [];
+ const realFetch = globalThis.fetch;
+ globalThis.fetch = async (urlArg, opts = {}) => {
+ const url = String(urlArg);
+ const method = (opts.method || "GET").toUpperCase();
+ calls.push({ method, url });
+ const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) });
+
+ if (method === "POST" && url.includes("/listConsentLinks")) return ok({ value: [{ link: "https://consent.example/redir" }] });
+ if (url.includes("/managedApis/") && !url.includes("export=true")) {
+ return ok({ properties: { connectionParameters: { token: { type: "oauthSetting" } } } });
+ }
+ if (method === "GET" && /\/mcpserverConfigs\?/.test(url)) return ok({ value: [configA, configB] });
+ if (method === "GET" && /\/connections\?/.test(url)) return ok({ value: [connA, connB] });
+ if (method === "GET" && /\/connectorGateways\/[^/?]+\?/.test(url)) return ok({ location: "eastus" });
+ throw new Error(`unexpected ARM call: ${method} ${url}`);
+ };
+ t.after(() => {
+ globalThis.fetch = realFetch;
+ });
+
+ const result = await reauthConnector(config, "docusign", "DocuSign", "https://cb/?c=");
+ assert.equal(existsSync(legacyAuthCache), false, "the legacy refresh-token cache must be removed without reading it");
+
+ // Adopts the existing connection, stops at consent, carries the selected config
+ // through so finish never mints a new one.
+ assert.equal(result.needsConsent, true);
+ assert.equal(result.reauth, true);
+ assert.equal(result.freshConnection, false);
+ assert.equal(result.connName, "conn-b"); // the inCli config's connection
+ assert.equal(result.configName, "docusign-bbb"); // never a fresh generateName()
+
+ // The core guarantee: nothing was minted. createConnection and
+ // createMcpServerConfig are the only PUTs on the install path; re-auth issues none.
+ const puts = calls.filter((c) => c.method === "PUT");
+ assert.deepEqual(puts, [], `expected zero PUTs, saw: ${puts.map((p) => p.url).join(", ")}`);
+
+ // And it re-consented the SELECTED connection, not the portal sibling.
+ const consent = calls.find((c) => c.url.includes("/listConsentLinks"));
+ assert.ok(consent && consent.url.includes("/connections/conn-b/"), "consent must target conn-b");
+ assert.ok(
+ !calls.some((c) => c.url.includes("/connections/conn-a/listConsentLinks")),
+ "must not touch the sibling connection conn-a",
+ );
+ assert.ok(!calls.some((c) => c.url.includes("export=true")), "reauth must not request unused swagger");
+});
+
+test("missing selected connection re-evaluates a valid duplicate before installing", async (t) => {
+ const configPath = join(TMP, "mcp-config.json");
+ writeFileSync(
+ configPath,
+ JSON.stringify({ mcpServers: { "api-dead": { type: "http", url: "https://example.com/mcp" } } }),
+ );
+ const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "gw1" };
+ const dead = { name: "api-dead", properties: { connectors: [{ name: "shared-api", connectionName: "conn-dead" }] } };
+ const live = { name: "api-live", properties: { connectors: [{ name: "shared-api", connectionName: "conn-live" }] } };
+ const calls = [];
+ const realFetch = globalThis.fetch;
+ globalThis.fetch = async (urlArg, opts = {}) => {
+ const url = String(urlArg);
+ const method = (opts.method || "GET").toUpperCase();
+ calls.push({ method, url });
+ const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) });
+ if (method === "GET" && /\/mcpserverConfigs\?/.test(url)) return ok({ value: [dead, live] });
+ if (method === "GET" && /\/connections\?/.test(url)) {
+ return ok({ value: [
+ { name: "conn-dead", properties: { statuses: [{ status: "Unknown" }] } },
+ { name: "conn-live", properties: { statuses: [{ status: "Connected" }] } },
+ ] });
+ }
+ if (method === "GET" && /\/connectorGateways\/[^/?]+\?/.test(url)) return ok({ location: "eastus" });
+ if (method === "GET" && url.includes("/managedApis/shared-api")) {
+ return ok({ properties: { connectionParameters: { token: { type: "oauthSetting" } } } });
+ }
+ if (method === "POST" && url.includes("/connections/conn-dead/listConsentLinks")) {
+ return { ok: false, status: 404, text: async () => "gone" };
+ }
+ if (method === "POST" && url.includes("/connections/conn-live/listConsentLinks")) {
+ return ok({ value: [{ link: "https://consent.example/live" }] });
+ }
+ if (method === "DELETE" && url.includes("/mcpserverConfigs/api-dead")) return ok({});
+ if (method === "GET" && url.includes("/mcpserverConfigs/api-dead")) {
+ return { ok: false, status: 404, text: async () => "gone" };
+ }
+ if (method === "DELETE" && url.includes("/connections/conn-dead")) return ok({});
+ throw new Error(`unexpected ARM call: ${method} ${url}`);
+ };
+ t.after(() => {
+ globalThis.fetch = realFetch;
+ writeFileSync(configPath, JSON.stringify({ mcpServers: {} }));
+ });
+
+ const result = await reauthConnector(config, "shared-api", "Shared API", "https://cb/?c=");
+ assert.equal(result.needsConsent, true);
+ assert.equal(result.configName, "api-live");
+ assert.equal(result.connName, "conn-live");
+ assert.ok(calls.some((call) => call.url.includes("/connections/conn-dead/listConsentLinks")));
+ assert.ok(calls.some((call) => call.url.includes("/connections/conn-live/listConsentLinks")));
+ assert.equal(calls.some((call) => call.method === "PUT"), false, "valid siblings must prevent a fresh install");
+});
+
+test("cross-process MCP config writes preserve every entry", async () => {
+ const configPath = join(TMP, "mcp-config.json");
+ writeFileSync(configPath, JSON.stringify({ mcpServers: {} }));
+ const installUrl = new URL("./install.mjs", import.meta.url).href;
+ const names = Array.from({ length: 8 }, (_, index) => `parallel-${index}`);
+
+ const runWriter = (name) => new Promise((resolve, reject) => {
+ const metadata = {
+ gatewayId: "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Web/connectorGateways/gateway",
+ mcpServerConfigId: `/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Web/connectorGateways/gateway/mcpserverConfigs/${name}`,
+ connectionId: `/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Web/connectorGateways/gateway/connections/${name}`,
+ apiName: name,
+ };
+ const script = [
+ `import { writeMcpEntry } from ${JSON.stringify(installUrl)};`,
+ `await writeMcpEntry(${JSON.stringify(name)}, ${JSON.stringify(`https://example.com/${name}`)}, ${JSON.stringify(`key-${name}`)}, "profile", ${JSON.stringify(metadata)});`,
+ ].join("\n");
+ const child = spawn(process.execPath, ["--input-type=module", "--eval", script], {
+ env: { ...process.env, COPILOT_HOME: TMP, HOME: TMP, USERPROFILE: TMP },
+ stdio: ["ignore", "ignore", "pipe"],
+ });
+ let stderr = "";
+ child.stderr.on("data", (chunk) => { stderr += chunk; });
+ child.once("error", reject);
+ child.once("exit", (code) => {
+ if (code === 0) resolve();
+ else reject(new Error(`config writer exited ${code}: ${stderr}`));
+ });
+ });
+
+ await Promise.all(names.map(runWriter));
+ const stored = JSON.parse(readFileSync(configPath, "utf8")).mcpServers;
+ assert.deepEqual(Object.keys(stored).sort(), [...names].sort());
+ for (const name of names) {
+ assert.equal(stored[name].url, `https://example.com/${name}`);
+ assert.equal(stored[name].headers["X-API-Key"], `key-${name}`);
+ assert.deepEqual(Object.keys(stored[name]).sort(), ["_connectorNamespace", "headers", "url"]);
+ assert.equal(stored[name]._connectorNamespace.apiName, name);
+ assert.match(stored[name]._connectorNamespace.gatewayId, /connectorGateways\/gateway$/);
+ }
+ assert.equal(existsSync(`${configPath}.lock`), false);
+
+ await Promise.all(names.map((name) => removeMcpEntry(name)));
+ assert.deepEqual(JSON.parse(readFileSync(configPath, "utf8")).mcpServers, {});
+});
+
+test("connector metadata failures are evicted and retried", async (t) => {
+ const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "gw1" };
+ const realFetch = globalThis.fetch;
+ let calls = 0;
+ globalThis.fetch = async (urlArg) => {
+ const url = String(urlArg);
+ assert.ok(!url.includes("export=true"), "swagger must not be requested when it is not required");
+ calls++;
+ if (calls === 1) return { ok: false, status: 400, text: async () => "temporary metadata failure" };
+ return {
+ ok: true,
+ status: 200,
+ text: async () => JSON.stringify({ properties: { connectionParameters: {} } }),
+ };
+ };
+ t.after(() => {
+ globalThis.fetch = realFetch;
+ });
+
+ await assert.rejects(loadConnectorMeta(config, "retry-meta", "eastus", false), /metadata failure/);
+ const meta = await loadConnectorMeta(config, "retry-meta", "eastus", false);
+ assert.equal(calls, 2);
+ assert.deepEqual(meta.connectionParameters, {});
+});
+
+test("uninstall surfaces connection deletion failures", async (t) => {
+ writeFileSync(
+ join(TMP, "mcp-config.json"),
+ JSON.stringify({ mcpServers: { "docusign-bbb": { type: "http", url: "https://example/mcp" } } }),
+ );
+ const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "gw1" };
+ const remoteConfig = { name: "docusign-bbb", properties: { connectors: [{ name: "docusign", connectionName: "conn-b" }] } };
+ const connection = { name: "conn-b", properties: { statuses: [{ status: "Connected" }] } };
+ const realFetch = globalThis.fetch;
+ const operations = [];
+ globalThis.fetch = async (urlArg, opts = {}) => {
+ const url = String(urlArg);
+ const method = (opts.method || "GET").toUpperCase();
+ operations.push(`${method} ${url}`);
+ const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) });
+ if (method === "GET" && /\/mcpserverConfigs\?/.test(url)) return ok({ value: [remoteConfig] });
+ if (method === "GET" && /\/connections\?/.test(url)) return ok({ value: [connection] });
+ if (method === "DELETE" && url.includes("/mcpserverConfigs/")) return ok({});
+ if (method === "GET" && url.includes("/mcpserverConfigs/")) {
+ return { ok: false, status: 404, text: async () => "gone" };
+ }
+ if (method === "DELETE" && url.includes("/connections/")) {
+ return { ok: false, status: 400, text: async () => "delete denied" };
+ }
+ throw new Error(`unexpected ARM call: ${method} ${url}`);
+ };
+ t.after(() => {
+ globalThis.fetch = realFetch;
+ });
+
+ await assert.rejects(uninstallConnector(config, "docusign"), /delete denied/);
+ const configDelete = operations.findIndex((item) => item.startsWith("DELETE ") && item.includes("/mcpserverConfigs/"));
+ const connectionDelete = operations.findIndex((item) => item.startsWith("DELETE ") && item.includes("/connections/"));
+ assert.ok(configDelete !== -1 && configDelete < connectionDelete, "configs must be confirmed deleted before their connections");
+
+ const pendingCleanup = join(TMP, "extensions", "connector-namespaces", "artifacts", "pending-cleanup");
+ assert.equal(readdirSync(pendingCleanup).filter((name) => name.endsWith(".json")).length, 1, "failed deletion must persist enough state to retry");
+
+ globalThis.fetch = async (urlArg, opts = {}) => {
+ const url = String(urlArg);
+ const method = (opts.method || "GET").toUpperCase();
+ const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) });
+ if (method === "GET" && /\/mcpserverConfigs\?/.test(url)) return ok({ value: [] });
+ if (method === "GET" && /\/connections\?/.test(url)) return ok({ value: [] });
+ if (method === "DELETE") return ok({});
+ if (method === "GET" && url.includes("/mcpserverConfigs/")) {
+ return { ok: false, status: 404, text: async () => "gone" };
+ }
+ throw new Error(`unexpected ARM call: ${method} ${url}`);
+ };
+ assert.deepEqual(await uninstallConnector(config, "docusign"), { ok: true, removed: true });
+ assert.equal(readdirSync(pendingCleanup).filter((name) => name.endsWith(".json")).length, 0, "successful retry must clear the cleanup journal");
+});
+
+test("uninstall surfaces convergence polling failures", async (t) => {
+ writeFileSync(
+ join(TMP, "mcp-config.json"),
+ JSON.stringify({ mcpServers: { "docusign-bbb": { type: "http", url: "https://example/mcp" } } }),
+ );
+ const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "gw1" };
+ const remoteConfig = { name: "docusign-bbb", properties: { connectors: [{ name: "docusign", connectionName: "conn-b" }] } };
+ const connection = { name: "conn-b", properties: { statuses: [{ status: "Connected" }] } };
+ const realFetch = globalThis.fetch;
+ globalThis.fetch = async (urlArg, opts = {}) => {
+ const url = String(urlArg);
+ const method = (opts.method || "GET").toUpperCase();
+ const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) });
+ if (method === "GET" && /\/mcpserverConfigs\?/.test(url)) return ok({ value: [remoteConfig] });
+ if (method === "GET" && url.includes("/mcpserverConfigs/")) {
+ return { ok: false, status: 400, text: async () => "poll denied" };
+ }
+ if (method === "GET" && /\/connections\?/.test(url)) return ok({ value: [connection] });
+ if (method === "DELETE") return ok({});
+ throw new Error(`unexpected ARM call: ${method} ${url}`);
+ };
+ t.after(() => {
+ globalThis.fetch = realFetch;
+ });
+
+ await assert.rejects(uninstallConnector(config, "docusign"), /poll denied/);
+});
+
+test("concurrent failed uninstalls retain independent retry records", async (t) => {
+ const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "concurrent-gw" };
+ const configs = [
+ { name: "alpha-config", properties: { connectors: [{ name: "alpha", connectionName: "alpha-conn" }] } },
+ { name: "beta-config", properties: { connectors: [{ name: "beta", connectionName: "beta-conn" }] } },
+ ];
+ const connections = [
+ { name: "alpha-conn", properties: { statuses: [{ status: "Connected" }] } },
+ { name: "beta-conn", properties: { statuses: [{ status: "Connected" }] } },
+ ];
+ const realFetch = globalThis.fetch;
+ globalThis.fetch = async (urlArg, opts = {}) => {
+ const url = String(urlArg);
+ const method = (opts.method || "GET").toUpperCase();
+ const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) });
+ if (method === "GET" && /\/mcpserverConfigs\?/.test(url)) return ok({ value: configs });
+ if (method === "GET" && /\/connections\?/.test(url)) return ok({ value: connections });
+ if (method === "DELETE" && url.includes("/mcpserverConfigs/")) return ok({});
+ if (method === "GET" && url.includes("/mcpserverConfigs/")) {
+ return { ok: false, status: 404, text: async () => "gone" };
+ }
+ if (method === "DELETE" && url.includes("/connections/")) {
+ return { ok: false, status: 400, text: async () => "delete denied" };
+ }
+ throw new Error(`unexpected ARM call: ${method} ${url}`);
+ };
+ t.after(() => {
+ globalThis.fetch = realFetch;
+ });
+
+ const results = await Promise.allSettled([
+ uninstallConnector(config, "alpha"),
+ uninstallConnector(config, "beta"),
+ ]);
+ assert.deepEqual(results.map((result) => result.status), ["rejected", "rejected"]);
+
+ const pendingCleanup = join(TMP, "extensions", "connector-namespaces", "artifacts", "pending-cleanup");
+ const paths = readdirSync(pendingCleanup)
+ .filter((name) => name.endsWith(".json"))
+ .map((name) => join(pendingCleanup, name));
+ const records = paths.map((path) => ({ path, ...JSON.parse(readFileSync(path, "utf8")) }))
+ .filter((record) => record.gatewayId.includes("/connectorGateways/concurrent-gw"));
+ assert.deepEqual(new Set(records.map((record) => record.apiName)), new Set(["alpha", "beta"]));
+ for (const record of records) unlinkSync(record.path);
+});
+
+test("local MCP config read failures block cleanup", async () => {
+ const configPath = join(TMP, "mcp-config.json");
+ writeFileSync(configPath, "{invalid json");
+ try {
+ await assert.rejects(removeMcpEntry("docusign-bbb"), SyntaxError);
+ } finally {
+ writeFileSync(configPath, JSON.stringify({ mcpServers: {} }));
+ }
+});
+
+test("installed state propagates local MCP config read failures", async (t) => {
+ const configPath = join(TMP, "mcp-config.json");
+ writeFileSync(configPath, "{invalid json");
+ const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "state-fail-gw" };
+ const realFetch = globalThis.fetch;
+ globalThis.fetch = async (urlArg, opts = {}) => {
+ const url = String(urlArg);
+ const method = (opts.method || "GET").toUpperCase();
+ const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) });
+ if (method === "GET" && /\/mcpserverConfigs\?/.test(url)) return ok({ value: [] });
+ if (method === "GET" && /\/connections\?/.test(url)) return ok({ value: [] });
+ throw new Error(`unexpected ARM call: ${method} ${url}`);
+ };
+ t.after(() => {
+ globalThis.fetch = realFetch;
+ writeFileSync(configPath, JSON.stringify({ mcpServers: {} }));
+ });
+
+ await assert.rejects(getInstalledState(config), SyntaxError);
+});
+
+test("missing-connection reauth journals cleanup and the next install retries it", async (t) => {
+ const configPath = join(TMP, "mcp-config.json");
+ writeFileSync(
+ configPath,
+ JSON.stringify({ mcpServers: { "missing-config": { type: "http", url: "https://example/mcp" } } }),
+ );
+ const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "missing-conn-gw" };
+ const remoteConfig = {
+ name: "missing-config",
+ properties: { connectors: [{ name: "missing-api", connectionName: "missing-conn" }] },
+ };
+ const realFetch = globalThis.fetch;
+ let retrying = false;
+ globalThis.fetch = async (urlArg, opts = {}) => {
+ const url = String(urlArg);
+ const method = (opts.method || "GET").toUpperCase();
+ const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) });
+ if (method === "DELETE" && url.includes("/mcpserverConfigs/")) return ok({});
+ if (method === "GET" && url.includes("/mcpserverConfigs/missing-config")) {
+ return { ok: false, status: 404, text: async () => "gone" };
+ }
+ if (method === "DELETE" && url.includes("/connections/missing-conn")) {
+ return { ok: false, status: 404, text: async () => "gone" };
+ }
+ if (retrying && method === "GET" && url.includes("/managedApis/missing-api")) {
+ return { ok: false, status: 400, text: async () => "stop after cleanup" };
+ }
+ if (method === "GET" && /\/mcpserverConfigs\?/.test(url)) return ok({ value: [remoteConfig] });
+ if (method === "GET" && /\/connections\?/.test(url)) return ok({ value: [] });
+ if (method === "GET" && /\/connectorGateways\/[^/?]+\?/.test(url)) return ok({ location: "eastus" });
+ if (method === "GET" && url.includes("/managedApis/missing-api")) return ok({ properties: {} });
+ if (method === "POST" && url.includes("/connections/missing-conn/listConsentLinks")) {
+ writeFileSync(configPath, "{invalid json");
+ return { ok: false, status: 404, text: async () => "connection gone" };
+ }
+ throw new Error(`unexpected ARM call: ${method} ${url}`);
+ };
+ t.after(() => {
+ globalThis.fetch = realFetch;
+ writeFileSync(configPath, JSON.stringify({ mcpServers: {} }));
+ });
+
+ await assert.rejects(
+ reauthConnector(config, "missing-api", "Missing API", "https://cb/?c="),
+ SyntaxError,
+ );
+
+ const pendingCleanup = join(TMP, "extensions", "connector-namespaces", "artifacts", "pending-cleanup");
+ const matchingRecords = () => readdirSync(pendingCleanup)
+ .filter((name) => name.endsWith(".json"))
+ .map((name) => JSON.parse(readFileSync(join(pendingCleanup, name), "utf8")))
+ .filter((record) => record.gatewayId.includes("/connectorGateways/missing-conn-gw") && record.apiName === "missing-api");
+ assert.equal(matchingRecords().length, 1, "failed reauth cleanup must retain retry data");
+
+ writeFileSync(
+ configPath,
+ JSON.stringify({ mcpServers: { "missing-config": { type: "http", url: "https://example/mcp" } } }),
+ );
+ retrying = true;
+ await assert.rejects(
+ installConnector(config, "missing-api", "Missing API", "https://cb/?c="),
+ /stop after cleanup/,
+ );
+ assert.equal(matchingRecords().length, 0, "the next install must consume successful pending cleanup");
+ const localConfig = JSON.parse(readFileSync(configPath, "utf8"));
+ assert.equal(localConfig.mcpServers["missing-config"], undefined, "pending cleanup must remove the stale local entry");
+});
+
+test("fresh-connection rollback surfaces deletion failures", async (t) => {
+ const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "gw1" };
+ const realFetch = globalThis.fetch;
+ globalThis.fetch = async () => ({ ok: false, status: 400, text: async () => "rollback denied" });
+ t.after(() => {
+ globalThis.fetch = realFetch;
+ });
+
+ await assert.rejects(deleteConnection(config, "fresh-conn"), /rollback denied/);
+});
+
+test("finish status failures roll back the fresh connection", async (t) => {
+ const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "gw1" };
+ const realFetch = globalThis.fetch;
+ const calls = [];
+ globalThis.fetch = async (urlArg, opts = {}) => {
+ const url = String(urlArg);
+ const method = (opts.method || "GET").toUpperCase();
+ calls.push({ method, url });
+ const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) });
+ if (method === "GET" && url.includes("/managedApis/") && url.includes("export=true")) {
+ return ok({ paths: { "/mcp": { post: { operationId: "op", tags: ["agentic"] } } } });
+ }
+ if (method === "GET" && url.includes("/managedApis/")) return ok({ properties: {} });
+ if (method === "GET" && url.includes("/connections/fresh-status?")) {
+ return { ok: false, status: 400, text: async () => "status denied" };
+ }
+ if (method === "DELETE" && url.includes("/connections/fresh-status?")) return ok({});
+ throw new Error(`unexpected ARM call: ${method} ${url}`);
+ };
+ t.after(() => {
+ globalThis.fetch = realFetch;
+ });
+
+ await assert.rejects(
+ finishInstall(config, "status-fail", "Status Fail", "fresh-status", "eastus"),
+ /status denied/,
+ );
+ assert.ok(calls.some((call) => call.method === "DELETE" && call.url.includes("/connections/fresh-status?")));
+});
+
+test("failed config cleanup preserves its referenced connection", async (t) => {
+ const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "gw1" };
+ const realFetch = globalThis.fetch;
+ const calls = [];
+ globalThis.fetch = async (urlArg, opts = {}) => {
+ const url = String(urlArg);
+ const method = (opts.method || "GET").toUpperCase();
+ calls.push({ method, url });
+ const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) });
+ if (method === "GET" && url.includes("/managedApis/") && url.includes("export=true")) {
+ return ok({ paths: { "/mcp": { post: { operationId: "op", tags: ["agentic"] } } } });
+ }
+ if (method === "GET" && url.includes("/managedApis/")) return ok({ properties: {} });
+ if (method === "GET" && url.includes("/connections/fresh-config?")) {
+ return ok({ properties: { statuses: [{ status: "Connected" }] } });
+ }
+ if (method === "PUT" && url.includes("/mcpserverConfigs/")) {
+ return ok({ properties: { mcpEndpointUrl: "https://example.com/mcp" } });
+ }
+ if (method === "POST" && url.includes("/listApiKey?")) {
+ return { ok: false, status: 400, text: async () => "key denied" };
+ }
+ if (method === "DELETE" && url.includes("/mcpserverConfigs/")) {
+ return { ok: false, status: 400, text: async () => "config cleanup denied" };
+ }
+ if (method === "DELETE" && url.includes("/connections/")) return ok({});
+ throw new Error(`unexpected ARM call: ${method} ${url}`);
+ };
+ t.after(() => {
+ globalThis.fetch = realFetch;
+ });
+
+ await assert.rejects(
+ finishInstall(config, "cleanup-order", "Cleanup Order", "fresh-config", "eastus"),
+ /config cleanup denied/,
+ );
+ assert.ok(
+ !calls.some((call) => call.method === "DELETE" && call.url.includes("/connections/")),
+ "a surviving config must keep its referenced connection",
+ );
+});
diff --git a/extensions/connector-namespaces/install.test.mjs b/extensions/connector-namespaces/install.test.mjs
new file mode 100644
index 00000000..5e51de54
--- /dev/null
+++ b/extensions/connector-namespaces/install.test.mjs
@@ -0,0 +1,253 @@
+// Regression guards for install-state selection.
+//
+// Run: node --test extensions/connector-namespaces/install.test.mjs
+//
+// These exist because getInstalledState used to collapse N gateway configs for
+// one apiName down to a single tile via ARM list order (last-wins). A portal
+// add, a duplicate Connect, or a re-auth would mint a sibling config; whichever
+// ARM happened to return last owned the tile, so a tile could show
+// "Re-authenticate" while a different config for the same connector was already
+// Connected. deriveInstalledState now picks deterministically:
+// inCli && Connected > inCli > Connected > any, configName wins ties.
+
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+
+import { deriveInstalledState, getConsentUrl, getConnectionStatus, getMcpEndpointUrl, waitForConnected } from "./install.mjs";
+
+// removeLocalEntry does file I/O (getInstalledState reads ARM + mcp configs,
+// removeMcpEntry edits them) and calls both as same-module functions, so there
+// is no import seam to stub. The invariant that matters — the default "Remove"
+// only unlinks the CLI entry and NEVER deletes the Azure resource — is a
+// source contract, so we assert it against the function body the same way
+// renderer.test.mjs guards its CSS/HTML strings.
+function functionBody(source, name) {
+ const exported = source.indexOf(`export async function ${name}(`);
+ const start = exported !== -1 ? exported : source.indexOf(`async function ${name}(`);
+ if (start === -1) return null;
+ const open = source.indexOf("{", start);
+ if (open === -1) return null;
+ let depth = 0;
+ for (let i = open; i < source.length; i++) {
+ const ch = source[i];
+ if (ch === "{") depth++;
+ else if (ch === "}") {
+ depth--;
+ if (depth === 0) return source.slice(open + 1, i);
+ }
+ }
+ return null;
+}
+
+const installSource = readFileSync(fileURLToPath(new URL("./install.mjs", import.meta.url)), "utf8");
+
+// Build a fake ARM mcpserverConfig list entry.
+function cfg(name, apiName, connName) {
+ return { name, properties: { connectors: [{ name: apiName, connectionName: connName }] } };
+}
+
+// Build a connName -> connection map with a given status.
+function conns(...entries) {
+ const m = new Map();
+ for (const [connName, status] of entries) {
+ m.set(connName, { name: connName, properties: { statuses: [{ status }] } });
+ }
+ return m;
+}
+
+test("picks inCli+Connected over a not-inCli sibling that appears LAST (not last-wins)", () => {
+ // good config is FIRST; a broken sibling is LAST. Old last-wins would pick
+ // the last one — the fix must pick the good one regardless of order.
+ const configs = [
+ cfg("good", "shared-api", "connGood"),
+ cfg("bad", "shared-api", "connBad"),
+ ];
+ const connByName = conns(["connGood", "Connected"], ["connBad", "Unknown"]);
+ const profileKeys = new Set(["good"]); // only the good config is in the CLI
+ const state = deriveInstalledState(configs, connByName, profileKeys, new Set(), null);
+
+ assert.equal(state["shared-api"].configName, "good");
+ assert.equal(state["shared-api"].connectionName, "connGood");
+ assert.equal(state["shared-api"].connectionStatus, "Connected");
+ assert.equal(state["shared-api"].inCli, true);
+ assert.equal(state["shared-api"]._configCount, 2);
+ assert.deepEqual(state["shared-api"]._candidates.map((item) => item.configName), ["good", "bad"]);
+});
+
+test("inCli beats a Connected-but-not-inCli sibling", () => {
+ // A is the config the local session points at but not yet Connected; B is
+ // Connected on ARM but not in the CLI. Prefer A so remove/re-auth act on the
+ // resource the user's session actually uses.
+ const configs = [
+ cfg("a-incli", "api", "connA"),
+ cfg("b-connected", "api", "connB"),
+ ];
+ const connByName = conns(["connA", "Unknown"], ["connB", "Connected"]);
+ const state = deriveInstalledState(configs, connByName, new Set(["a-incli"]), new Set(), null);
+
+ assert.equal(state["api"].configName, "a-incli");
+ assert.equal(state["api"].inCli, true);
+});
+
+test("inCli && Connected beats inCli-only", () => {
+ const configs = [
+ cfg("incli-unknown", "api", "connU"),
+ cfg("incli-connected", "api", "connC"),
+ ];
+ const connByName = conns(["connU", "Unknown"], ["connC", "Connected"]);
+ const state = deriveInstalledState(configs, connByName, new Set(["incli-unknown", "incli-connected"]), new Set(), null);
+
+ assert.equal(state["api"].configName, "incli-connected");
+ assert.equal(state["api"].connectionStatus, "Connected");
+});
+
+test("config name breaks equal-rank ties independently of ARM list order", () => {
+ const configs = [
+ cfg("z-config", "api", "connZ"),
+ cfg("a-config", "api", "connA"),
+ ];
+ const connByName = conns(["connZ", "Connected"], ["connA", "Connected"]);
+ const local = new Set(["z-config", "a-config"]);
+
+ const forward = deriveInstalledState(configs, connByName, local, new Set(), null);
+ const reverse = deriveInstalledState([...configs].reverse(), connByName, local, new Set(), null);
+
+ assert.equal(forward.api.configName, "a-config");
+ assert.equal(reverse.api.configName, "a-config");
+});
+
+test("connection convergence reports non-connected terminal results as failures", async () => {
+ const states = ["Connecting", "Error"];
+ const delays = [];
+ await assert.rejects(
+ waitForConnected({}, "conn", {
+ maxPolls: 2,
+ getStatus: async () => states.shift(),
+ delay: async (ms) => delays.push(ms),
+ }),
+ /Connection ended in state "Error"/,
+ );
+ assert.deepEqual(delays, [1000]);
+ assert.equal(
+ await waitForConnected({}, "conn", {
+ getStatus: async () => "Connected",
+ delay: async () => assert.fail("connected state must not sleep"),
+ }),
+ "Connected",
+ );
+});
+
+test("single config passes through with no _configCount", () => {
+ const configs = [cfg("only", "api", "conn1")];
+ const connByName = conns(["conn1", "Connected"]);
+ const state = deriveInstalledState(configs, connByName, new Set(["only"]), new Set(), null);
+
+ assert.equal(state["api"].configName, "only");
+ assert.equal(state["api"]._configCount, undefined);
+});
+
+test("workspace membership counts as inCli and sets scope/path", () => {
+ const configs = [cfg("ws", "api", "conn1")];
+ const connByName = conns(["conn1", "Connected"]);
+ const state = deriveInstalledState(configs, connByName, new Set(), new Set(["ws"]), "/repo/.mcp.json");
+
+ assert.equal(state["api"].inCli, true);
+ assert.equal(state["api"].cliScope, "workspace");
+ assert.equal(state["api"].cliPath, "/repo/.mcp.json");
+});
+
+test("connectionStatus falls back to overallStatus then Unknown", () => {
+ const configs = [cfg("c1", "api1", "connOverall"), cfg("c2", "api2", "connMissing")];
+ const connByName = new Map([
+ ["connOverall", { name: "connOverall", properties: { overallStatus: "Connected" } }],
+ ]);
+ const state = deriveInstalledState(configs, connByName, new Set(), new Set(), null);
+
+ assert.equal(state["api1"].connectionStatus, "Connected"); // from overallStatus
+ assert.equal(state["api2"].connectionStatus, "Unknown"); // no connection at all
+});
+
+test("configs with no connector are skipped", () => {
+ const configs = [
+ { name: "broken", properties: { connectors: [] } },
+ cfg("ok", "api", "conn1"),
+ ];
+ const connByName = conns(["conn1", "Connected"]);
+ const state = deriveInstalledState(configs, connByName, new Set(["ok"]), new Set(), null);
+
+ assert.equal(Object.keys(state).length, 1);
+ assert.equal(state["api"].configName, "ok");
+});
+
+test("removeLocalEntry unlinks the local CLI entry via removeMcpEntry", () => {
+ const body = functionBody(installSource, "removeLocalEntry");
+ assert.ok(body, "removeLocalEntry function not found in install.mjs");
+ assert.match(body, /removeMcpEntry\s*\(/, "removeLocalEntry must call removeMcpEntry to drop the CLI entry");
+ assert.match(body, /entry\._candidates/, "removeLocalEntry must process duplicate CLI configs");
+ assert.match(body, /candidate\.inCli/, "removeLocalEntry must unlink every local candidate");
+});
+
+test("uninstallConnector deletes every duplicate namespace config", () => {
+ const body = functionBody(installSource, "uninstallConnector");
+ const cleanup = functionBody(installSource, "cleanupConnectorResources");
+ assert.ok(body, "uninstallConnector function not found in install.mjs");
+ assert.ok(cleanup, "cleanupConnectorResources function not found in install.mjs");
+ assert.match(body, /entry\._candidates/, "namespace deletion must process duplicate configs");
+ assert.match(body, /cleanupConnectorResources\s*\(/, "uninstall must delegate all collected candidates to shared cleanup");
+ assert.match(cleanup, /deleteMcpServerConfigs\(config, configNames\)/);
+ assert.match(cleanup, /for \(const connectionName of connectionNames\)/);
+ assert.match(cleanup, /for \(const configName of configNames\)/);
+});
+
+test("removeLocalEntry never deletes the namespace resource (no armDelete)", () => {
+ const body = functionBody(installSource, "removeLocalEntry");
+ assert.ok(body, "removeLocalEntry function not found in install.mjs");
+ // The default Remove must stay local-only. If someone routes it through
+ // uninstallConnector or adds an ARM delete, this fails — which is the point.
+ assert.doesNotMatch(body, /armDelete\s*\(/, "removeLocalEntry must not call armDelete");
+ assert.doesNotMatch(body, /uninstallConnector\s*\(/, "removeLocalEntry must not delegate to uninstallConnector");
+});
+
+// --- ARM path-injection guard (client-reachable read sinks) ---
+//
+// finishInstall/finishReauth feed client-supplied body.connName / body.configName
+// into getConsentUrl, getConnectionStatus, and getMcpEndpointUrl, which build ARM
+// URLs. Those names must pass through armSegment() so a traversal / query payload
+// can't escape the intended resource path (SSRF / path injection). armSegment
+// throws synchronously while the URL is built, before any token or network call,
+// so these run fully offline and deterministic. A valid config is used so the
+// gatewayId() wrap doesn't throw first — only the bad NAME should reject.
+const validConfig = { subscriptionId: "s", resourceGroup: "r", gatewayName: "g" };
+const badNames = ["../../evil", "evil/../../secret", "x?injected=1"];
+
+test("getConnectionStatus rejects traversal/injection connName before any ARM call", async () => {
+ for (const bad of badNames) {
+ await assert.rejects(
+ () => getConnectionStatus(validConfig, bad),
+ /Invalid ARM resource identifier/,
+ `getConnectionStatus should reject connName ${JSON.stringify(bad)}`,
+ );
+ }
+});
+
+test("getConsentUrl rejects traversal/injection connName before any ARM call", async () => {
+ for (const bad of badNames) {
+ await assert.rejects(
+ () => getConsentUrl(validConfig, bad, "http://127.0.0.1:0/auth/callback/x"),
+ /Invalid ARM resource identifier/,
+ `getConsentUrl should reject connName ${JSON.stringify(bad)}`,
+ );
+ }
+});
+
+test("getMcpEndpointUrl rejects traversal/injection configName before any ARM call", async () => {
+ for (const bad of badNames) {
+ await assert.rejects(
+ () => getMcpEndpointUrl(validConfig, bad),
+ /Invalid ARM resource identifier/,
+ `getMcpEndpointUrl should reject configName ${JSON.stringify(bad)}`,
+ );
+ }
+});
diff --git a/extensions/connector-namespaces/mcp-http-probe.test.mjs b/extensions/connector-namespaces/mcp-http-probe.test.mjs
new file mode 100644
index 00000000..009f1086
--- /dev/null
+++ b/extensions/connector-namespaces/mcp-http-probe.test.mjs
@@ -0,0 +1,58 @@
+import assert from "node:assert/strict";
+import { createServer } from "node:http";
+import test from "node:test";
+import { probe } from "./test/mcp-probe.mjs";
+
+test("native HTTP probe carries API key and MCP session through an SSE handshake", async (t) => {
+ const apiKeys = [];
+ const sessionIds = [];
+ const methods = [];
+ const server = createServer(async (req, res) => {
+ const chunks = [];
+ for await (const chunk of req) chunks.push(chunk);
+ const message = JSON.parse(Buffer.concat(chunks).toString("utf8"));
+ apiKeys.push(req.headers["x-api-key"]);
+ sessionIds.push(req.headers["mcp-session-id"] || null);
+ methods.push(message.method);
+
+ if (message.method === "notifications/initialized") {
+ res.writeHead(202);
+ res.end();
+ return;
+ }
+
+ let result;
+ if (message.method === "initialize") {
+ res.setHeader("Mcp-Session-Id", "session-1");
+ result = {
+ protocolVersion: "2025-06-18",
+ serverInfo: { name: "test-server", version: "1.0.0" },
+ capabilities: { tools: {} },
+ };
+ } else if (message.method === "tools/list") {
+ result = { tools: [{ name: "ListTeams", inputSchema: { type: "object" } }] };
+ } else {
+ result = { content: [{ type: "text", text: "ok" }] };
+ }
+
+ res.setHeader("Content-Type", "text/event-stream");
+ res.end(`event: message\ndata: ${JSON.stringify({ jsonrpc: "2.0", id: message.id, result })}\n\n`);
+ });
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ t.after(() => new Promise((resolve) => server.close(resolve)));
+
+ const { port } = server.address();
+ const result = await probe({
+ apiName: "WorkIQTeams",
+ displayName: "WorkIQ Teams",
+ url: `http://127.0.0.1:${port}/mcp`,
+ key: "secret",
+ });
+
+ assert.equal(result.ok, true);
+ assert.equal(result.toolCount, 1);
+ assert.equal(result.toolCalled, "ListTeams");
+ assert.deepEqual(methods, ["initialize", "notifications/initialized", "tools/list", "tools/call"]);
+ assert.deepEqual(apiKeys, ["secret", "secret", "secret", "secret"]);
+ assert.deepEqual(sessionIds, [null, "session-1", "session-1", "session-1"]);
+});
diff --git a/extensions/connector-namespaces/package.json b/extensions/connector-namespaces/package.json
new file mode 100644
index 00000000..ac0c28b0
--- /dev/null
+++ b/extensions/connector-namespaces/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "connector-namespaces",
+ "version": "1.1.0",
+ "type": "module",
+ "main": "extension.mjs",
+ "description": "Browse, connect, and open MCP connectors from your Azure Connector Namespace in Sandbox.",
+ "keywords": [
+ "azure",
+ "connector-namespace",
+ "mcp",
+ "mcp-connectors",
+ "model-context-protocol",
+ "tool-discovery"
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@github/copilot-sdk": "1.0.6"
+ }
+}
diff --git a/extensions/connector-namespaces/preview/.gitignore b/extensions/connector-namespaces/preview/.gitignore
new file mode 100644
index 00000000..0bac4c45
--- /dev/null
+++ b/extensions/connector-namespaces/preview/.gitignore
@@ -0,0 +1,2 @@
+# Screenshot evidence is throwaway, regenerated on demand by shots.mjs.
+shots/
diff --git a/extensions/connector-namespaces/preview/README.md b/extensions/connector-namespaces/preview/README.md
new file mode 100644
index 00000000..14b839b1
--- /dev/null
+++ b/extensions/connector-namespaces/preview/README.md
@@ -0,0 +1,112 @@
+# connector-namespaces preview harness
+
+A standalone way to **see** every canvas state without launching the Copilot
+app. It imports the real, pure renderer functions from `../renderer.mjs` and
+serves each state on a fixed loopback port, with every `/api/*` endpoint stubbed
+so you can force the states that keep regressing (the connecting spinner and the
+"Restart your Copilot session…" banner).
+
+This exists because those two bugs have each shipped multiple times:
+
+- the sign-in spinner freezing (an unscoped `animation:none` leaking out of the
+ reduced-motion block), and
+- the restart-banner dismiss button doing nothing (a CSS specificity bug that
+ let `.restart-banner{display:flex}` beat `[hidden]`).
+
+Both are static CSS facts, so the **deterministic gate is `../renderer.test.mjs`**
+(run with `node --test`). This harness is the human-visual layer on top of it:
+load a state in a browser, or capture screenshots with `agent-browser`.
+
+## Run the preview server
+
+```sh
+node extensions/connector-namespaces/preview/server.mjs
+```
+
+It binds to `http://127.0.0.1:7331`. Open that URL in any browser. The server is
+a plain HTTP process (not the JSON-RPC extension provider), so it logs every hit
+to stdout — that's expected and fine here.
+
+### State routes
+
+| URL | State |
+| --- | --- |
+| `/` or `/catalog` | Configured catalog (mock gateway + connectors) |
+| `/setup` | First-run gateway picker (`renderSetupHtml`) |
+| `/error` | Error screen (`renderErrorHtml`) |
+
+### State-forcing query flags (on the catalog route)
+
+The catalog page hydrates from `/api/state` on load, so loading one of these
+sets the state the very next `/api/state` returns:
+
+| Flag | Effect |
+| --- | --- |
+| `/?restart=1` | `/api/state` returns `pendingRestart:true` → restart banner visible on load |
+| `/?installed=1` | One connector shows as already installed/connected |
+
+Flags combine, e.g. `/?installed=1&restart=1`.
+
+> The active state is a single module-level flag (last catalog load wins). It's a
+> single-user preview, so just load the page you want, then it's sticky until the
+> next catalog load.
+
+### Stubbed endpoints
+
+`/api/state`, `/api/gateways`, `/api/select-gateway`, `/api/install` (returns
+`needsConsent` to force the connecting spinner), `/api/finish-install`,
+`/api/ack-restart` (the dismiss action), `/oauth-status` (stays pending so the
+modal spinner keeps animating), `/api/uninstall`, `/api/rollback-connection`,
+and `/api/open-url` (a deliberate **no-op** here — it must never actually launch
+a browser tab).
+
+## Capture screenshots (optional)
+
+The screenshot driver uses [`agent-browser`](https://www.npmjs.com/package/agent-browser),
+the same headless-Chromium verification tool that `arikbidny/ralph-copilot-cli`
+uses. It is **not** required — if it isn't installed the driver prints an install
+hint and exits 0.
+
+Install it once:
+
+```sh
+npm i -g agent-browser && agent-browser install
+```
+
+Then, with the server running in another terminal:
+
+```sh
+node extensions/connector-namespaces/preview/shots.mjs
+```
+
+Screenshots are written to `preview/shots/`:
+
+- `catalog.png`, `catalog-restart-banner.png`, `catalog-installed.png`,
+ `setup.png`, `error.png` — the static states.
+- `connecting-spinner.png` — after clicking **Connect**; verify the `.si-spin`
+ ring is mid-rotation, not frozen.
+- `banner-before-dismiss.png` / `banner-after-dismiss.png` — verify the banner is
+ present in the first and **gone** in the second.
+
+`preview/shots/` is throwaway visual evidence; it is not committed.
+
+## Files
+
+| File | Purpose |
+| --- | --- |
+| `server.mjs` | Standalone preview server (fixed port 7331) |
+| `fixtures.mjs` | Deterministic mock subscriptions / gateways / catalog / state |
+| `shots.mjs` | `agent-browser` screenshot driver (degrades gracefully) |
+
+## Relationship to the test guard
+
+`shots.mjs` proves a state *looks* right today and is handy when chasing a new
+bug. It cannot prove an animation is *running* from a single frame. The
+regression gate that actually blocks the recurring bugs is the CSS-structure
+assertion in `../renderer.test.mjs`:
+
+```sh
+node --test extensions/connector-namespaces/renderer.test.mjs
+```
+
+Keep that green; use this harness to eyeball changes.
diff --git a/extensions/connector-namespaces/preview/fixtures.mjs b/extensions/connector-namespaces/preview/fixtures.mjs
new file mode 100644
index 00000000..0fce100b
--- /dev/null
+++ b/extensions/connector-namespaces/preview/fixtures.mjs
@@ -0,0 +1,117 @@
+// Deterministic fixtures for the standalone canvas preview server.
+//
+// These mirror the exact response shapes the inline client script in
+// renderer.mjs expects, so the preview server can drive every canvas state
+// (setup / catalog / error / connecting-spinner / restart-banner) with no
+// Copilot app, no ARM, and no real OAuth. Keep these shapes in sync with the
+// fetch() handlers in renderer.mjs if those response contracts change.
+
+import { CATEGORY } from "../categories.mjs";
+
+export const subscriptions = [
+ { id: "00000000-0000-0000-0000-000000000001", name: "Contoso Production" },
+ { id: "11111111-1111-1111-1111-111111111111", name: "Contoso Dev/Test" },
+];
+
+// /api/gateways?subscriptionId=... -> { gateways: [{ id, name, location }], hasMore }
+// The client splits id on "/" and reads the segment after "resourceGroups",
+// so the id must contain a resourceGroups segment.
+export const gateways = [
+ {
+ id: "/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/rg-connectors/providers/Microsoft.ConnectorNamespaces/connectorNamespaces/contoso-ns",
+ name: "contoso-ns",
+ location: "eastus",
+ },
+ {
+ id: "/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/rg-shared/providers/Microsoft.ConnectorNamespaces/connectorNamespaces/shared-ns",
+ name: "shared-ns",
+ location: "westus2",
+ },
+];
+
+// Active namespace shown in the catalog header (config.gatewayName / resourceGroup).
+export const config = {
+ subscriptionId: "00000000-0000-0000-0000-000000000001",
+ gatewayName: "contoso-ns",
+ resourceGroup: "rg-connectors",
+};
+
+// Catalog tiles. Shape per item: { category, displayName, apiName, description,
+// iconUri?, brandColor? }. At least one item must be connectable so the
+// connect -> spinner flow can be exercised.
+//
+// The renderer routes items by category: exactly `category === CATEGORY.microsoft`
+// lands in the Microsoft section, everything else in Partners. Keep a mix of
+// both here so the preview exercises the full 3-section layout (My MCPs /
+// Microsoft / Partners) rather than dumping every tile into one section.
+export const catalog = [
+ {
+ category: CATEGORY.microsoft,
+ displayName: "Microsoft Teams",
+ apiName: "teams",
+ description: "Send messages, manage chats and channels.",
+ brandColor: "#5059c9",
+ },
+ {
+ category: CATEGORY.microsoft,
+ displayName: "Outlook Mail",
+ apiName: "outlook",
+ description: "Read, send, and organize email.",
+ brandColor: "#0a66c2",
+ },
+ {
+ category: CATEGORY.microsoft,
+ displayName: "SharePoint",
+ apiName: "sharepoint",
+ description: "Browse sites, lists, and documents.",
+ brandColor: "#038387",
+ },
+ {
+ category: CATEGORY.partner,
+ displayName: "GitHub",
+ apiName: "github",
+ description: "Manage repos, issues, and pull requests.",
+ brandColor: "#24292e",
+ },
+ {
+ category: CATEGORY.partner,
+ displayName: "Stripe",
+ apiName: "stripe",
+ description: "Payments, customers, and invoices.",
+ brandColor: "#635bff",
+ },
+];
+
+// /api/state -> { state: { apiName: InstallState }, pendingRestart }
+// InstallState: { installed, connectionStatus, inCli, cliPath?, cliScope? }
+// Default state: nothing installed, no pending restart. The catalog renders
+// every tile with a "Connect" button.
+export const stateEmpty = {
+ state: {},
+ pendingRestart: false,
+};
+
+// One connector already added (shows "Added" + Remove), restart pending so the
+// banner is visible on load. Drives both the "added" tile and the banner state.
+export const stateInstalledRestart = {
+ state: {
+ sharepoint: {
+ installed: true,
+ connectionStatus: "Connected",
+ inCli: true,
+ cliPath: "~/.copilot/mcp-config.json",
+ cliScope: "profile",
+ },
+ },
+ pendingRestart: true,
+};
+
+// Install response that forces the connecting flow. needsConsent keeps the
+// sign-in modal (with the .si-spin spinner) open; /oauth-status then stays
+// pending so the spinner keeps animating for a screenshot.
+export const installNeedsConsent = {
+ needsConsent: true,
+ connName: "preview-conn",
+ consentUrl: "http://127.0.0.1:7331/fake-consent",
+ location: "eastus",
+};
diff --git a/extensions/connector-namespaces/preview/server.mjs b/extensions/connector-namespaces/preview/server.mjs
new file mode 100644
index 00000000..d56a14c6
--- /dev/null
+++ b/extensions/connector-namespaces/preview/server.mjs
@@ -0,0 +1,151 @@
+// Standalone preview server for the connector-namespaces connector catalog.
+//
+// Renders every canvas state with no Copilot app, no ARM, and no real OAuth by
+// importing the *pure* HTML builders from renderer.mjs and stubbing every
+// /api/* endpoint the inline client script calls. Point any browser (or the
+// agent-browser driver in shots.mjs) at it to see exactly what ships.
+//
+// Run: node extensions/connector-namespaces/preview/server.mjs
+// Then open http://127.0.0.1:7331/ (catalog), /setup, /error.
+//
+// This process is NOT the JSON-RPC extension provider, so console.log here is
+// fine and intentional — it is how you watch which stubbed endpoints get hit.
+
+import { createServer } from "node:http";
+
+import {
+ renderCatalogHtml,
+ renderSetupHtml,
+ renderErrorHtml,
+} from "../renderer.mjs";
+import * as fixtures from "./fixtures.mjs";
+
+const HOST = "127.0.0.1";
+const PORT = 7331;
+const INSTANCE = "preview";
+
+// Whatever /api/state should report next. The catalog route updates this from
+// its query flags so a page load can force the banner / "added" tile on, and a
+// real Connect click flips pendingRestart on via showRestartBanner().
+let activeState = fixtures.stateEmpty;
+
+function selectState(query) {
+ const restart = query.get("restart") === "1";
+ const installed = query.get("installed") === "1";
+ if (restart && installed) return fixtures.stateInstalledRestart;
+ if (installed) return { state: fixtures.stateInstalledRestart.state, pendingRestart: false };
+ if (restart) return { state: {}, pendingRestart: true };
+ return fixtures.stateEmpty;
+}
+
+function sendHtml(res, body) {
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
+ res.end(body);
+}
+
+function sendJson(res, obj, status = 200) {
+ res.statusCode = status;
+ res.setHeader("Content-Type", "application/json");
+ res.end(JSON.stringify(obj));
+}
+
+async function readBody(req) {
+ const chunks = [];
+ for await (const chunk of req) chunks.push(chunk);
+ if (!chunks.length) return {};
+ try {
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
+ } catch {
+ return {};
+ }
+}
+
+const server = createServer(async (req, res) => {
+ const url = new URL(req.url, `http://${HOST}:${PORT}`);
+ const path = url.pathname;
+ const q = url.searchParams;
+ // Strip CR/LF/tab so a crafted request line can't forge extra log entries.
+ console.log(`${req.method} ${req.url}`.replace(/[\r\n\t]/g, " "));
+
+ // --- Page routes ---------------------------------------------------------
+ if (req.method === "GET" && (path === "/" || path === "/catalog")) {
+ activeState = selectState(q);
+ return sendHtml(
+ res,
+ renderCatalogHtml(INSTANCE, fixtures.catalog, {
+ filter: q.get("filter") || "",
+ category: q.get("category") || "all",
+ source: q.get("source") || "",
+ config: fixtures.config,
+ }),
+ );
+ }
+ if (req.method === "GET" && path === "/setup") {
+ return sendHtml(res, renderSetupHtml(fixtures.subscriptions));
+ }
+ if (req.method === "GET" && path === "/error") {
+ return sendHtml(res, renderErrorHtml(q.get("message") || "Something went wrong loading connectors."));
+ }
+ if (req.method === "GET" && path === "/fake-consent") {
+ return sendHtml(res, "Consent Fake Microsoft consent page (preview). Close this tab.");
+ }
+
+ // --- Stubbed API endpoints ----------------------------------------------
+ if (req.method === "GET" && path === "/api/state") {
+ return sendJson(res, activeState);
+ }
+ if (req.method === "GET" && path === "/api/gateways") {
+ return sendJson(res, { gateways: fixtures.gateways, hasMore: false });
+ }
+ if (req.method === "GET" && path === "/oauth-status") {
+ // Stay pending forever so the connecting spinner keeps animating for a
+ // screenshot. Flip to { done: true } if you want the full success flow.
+ return sendJson(res, { done: false });
+ }
+
+ if (req.method === "POST") {
+ await readBody(req);
+ switch (path) {
+ case "/api/select-gateway":
+ return sendJson(res, { ok: true });
+ case "/api/install":
+ return sendJson(res, fixtures.installNeedsConsent);
+ case "/api/finish-install":
+ activeState = { ...activeState, pendingRestart: true };
+ return sendJson(res, { ok: true });
+ case "/api/ack-restart":
+ activeState = { ...activeState, pendingRestart: false };
+ return sendJson(res, { ok: true });
+ case "/api/uninstall":
+ return sendJson(res, { ok: true });
+ case "/api/open-url":
+ // Preview no-op: do NOT actually launch a browser tab.
+ return sendJson(res, { ok: true });
+ case "/api/rollback-connection":
+ return sendJson(res, { ok: true });
+ default:
+ return sendJson(res, { error: `unstubbed POST ${path}` }, 404);
+ }
+ }
+
+ res.statusCode = 404;
+ res.end("not found");
+});
+
+server.on("error", (err) => {
+ if (err.code === "EADDRINUSE") {
+ console.error(`Port ${PORT} is already in use. Stop the other process or change PORT in server.mjs.`);
+ process.exit(1);
+ }
+ throw err;
+});
+
+server.listen(PORT, HOST, () => {
+ console.log(`canvas preview server: http://${HOST}:${PORT}/`);
+ console.log(" / catalog (empty state)");
+ console.log(" /?restart=1 catalog with restart banner visible");
+ console.log(" /?installed=1 catalog with one connector added");
+ console.log(" /setup namespace picker");
+ console.log(" /error error state");
+ console.log("Press Ctrl+C to stop.");
+});
diff --git a/extensions/connector-namespaces/preview/shots.mjs b/extensions/connector-namespaces/preview/shots.mjs
new file mode 100644
index 00000000..aab8f17b
--- /dev/null
+++ b/extensions/connector-namespaces/preview/shots.mjs
@@ -0,0 +1,96 @@
+// agent-browser screenshot driver for the canvas preview server.
+//
+// Captures every canvas state to ./shots/ and drives the two interaction flows
+// that keep regressing:
+// 1. catalog -> click Connect -> sign-in modal with the spinning .si-spin
+// 2. restart banner visible -> click dismiss -> banner gone
+//
+// Requires the preview server to be running:
+// node extensions/connector-namespaces/preview/server.mjs
+// And agent-browser installed:
+// npm i -g agent-browser && agent-browser install
+//
+// If agent-browser is not installed, this script prints how to install it and
+// exits 0 (so it never breaks an unattended run). This is a visual-evidence
+// helper; the deterministic regression gate is renderer.test.mjs.
+
+import { spawnSync } from "node:child_process";
+import { mkdirSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const SHOTS = join(HERE, "shots");
+const BASE = "http://127.0.0.1:7331";
+
+function hasAgentBrowser() {
+ const probe = spawnSync("agent-browser", ["--version"], { encoding: "utf8", shell: true });
+ return probe.status === 0;
+}
+
+function ab(args) {
+ const r = spawnSync("agent-browser", args, { encoding: "utf8", shell: true });
+ if (r.status !== 0) {
+ console.error(`agent-browser ${args.join(" ")} failed:\n${r.stderr || r.stdout}`);
+ }
+ return r;
+}
+
+function serverUp() {
+ // Node 18+ has global fetch. Confirm the preview server is reachable.
+ return fetch(`${BASE}/api/state`).then(() => true).catch(() => false);
+}
+
+async function main() {
+ if (!hasAgentBrowser()) {
+ console.log("agent-browser is not installed -> skipping screenshots.");
+ console.log("Install it with: npm i -g agent-browser && agent-browser install");
+ console.log("Then re-run: node extensions/connector-namespaces/preview/shots.mjs");
+ process.exit(0);
+ }
+
+ if (!(await serverUp())) {
+ console.error("preview server is not reachable at " + BASE);
+ console.error("start it first: node extensions/connector-namespaces/preview/server.mjs");
+ process.exit(1);
+ }
+
+ mkdirSync(SHOTS, { recursive: true });
+
+ // Static states.
+ const states = [
+ ["catalog", `${BASE}/`],
+ ["catalog-restart-banner", `${BASE}/?restart=1`],
+ ["catalog-installed", `${BASE}/?installed=1`],
+ ["setup", `${BASE}/setup`],
+ ["error", `${BASE}/error`],
+ ];
+ for (const [name, target] of states) {
+ ab(["open", target]);
+ ab(["screenshot", join(SHOTS, `${name}.png`)]);
+ console.log(`captured ${name}`);
+ }
+
+ // Flow 1: connect -> connecting spinner. The preview /api/install returns
+ // needsConsent and /oauth-status stays pending, so the .si-spin modal
+ // spinner keeps animating. Best-effort selector; adjust if markup changes.
+ ab(["open", `${BASE}/`]);
+ ab(["click", ".item-add[data-api]"]);
+ ab(["screenshot", join(SHOTS, "connecting-spinner.png")]);
+ console.log("captured connecting-spinner (verify the spinner is mid-rotation)");
+
+ // Flow 2: banner -> dismiss -> gone. Screenshot before and after the click
+ // so a frozen/broken dismiss button is visible as a diff.
+ ab(["open", `${BASE}/?restart=1`]);
+ ab(["screenshot", join(SHOTS, "banner-before-dismiss.png")]);
+ ab(["click", ".restart-banner .rb-dismiss"]);
+ ab(["screenshot", join(SHOTS, "banner-after-dismiss.png")]);
+ console.log("captured banner-before-dismiss / banner-after-dismiss (after should have no banner)");
+
+ console.log(`\nshots written to ${SHOTS}`);
+}
+
+main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/extensions/connector-namespaces/renderer.mjs b/extensions/connector-namespaces/renderer.mjs
new file mode 100644
index 00000000..e4ddf609
--- /dev/null
+++ b/extensions/connector-namespaces/renderer.mjs
@@ -0,0 +1,1563 @@
+// Renderers for the connector namespace picker and connector catalog pages.
+// Styled to match the reference connector extension UI.
+
+import { CATEGORY } from "./categories.mjs";
+import { buildSandboxUrl } from "./sandbox.mjs";
+
+const CONNECT_ICON = ' ';
+
+// Official Azure Connector Namespace mark — a gray viewfinder frame wrapping
+// two interlocking blue-gradient chain links. Path + gradient data is lifted
+// verbatim from the portal's ConnectorNamespaceIcon brand asset. idSuffix keeps
+// the gradient element IDs unique when the mark renders more than once per page.
+export function brandMark(size = 28, idSuffix = "m") {
+ const g0 = `cn-g0-${idSuffix}`;
+ const g1 = `cn-g1-${idSuffix}`;
+ return ``
+ + ``
+ + ``
+ + ` `
+ + ` `
+ + ``
+ + ` `
+ + ` `
+ + ` `
+ + ` `
+ + ` `
+ + ` `
+ + ` `
+ + ` `
+ + ` `
+ + ` `
+ + ` `
+ + ` `
+ + ` `
+ + ` `
+ + ` `
+ + ` `;
+}
+
+export function baseStyles() {
+ return ``;
+}
+
+// ---------------------------------------------------------------------------
+// Setup / Namespace Picker
+// ---------------------------------------------------------------------------
+
+export function renderSetupHtml(subscriptions, notice = "", capabilityToken = "") {
+ const subOptions = subscriptions.map((s) =>
+ `${esc(s.name)} (${s.id.slice(0, 8)}\u2026) `
+ ).join("");
+
+ return `
+
+Select Connector Namespace ${baseStyles()}
+
+
+
${brandMark(30, "setup")}Select a Connector Namespace
+
Choose which connector namespace to browse. This choice is saved for future sessions.
+
+${notice ? `${esc(notice)}
` : ""}
+
+ + New connector namespace
+
+
+ Subscription
+
+ -- Select subscription --
+ ${subOptions}
+
+
+
+
+
Select a subscription to see available connector namespaces.
+
+`;
+}
+
+// ---------------------------------------------------------------------------
+// Catalog
+// ---------------------------------------------------------------------------
+
+const CSS_HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
+
+function iconBackgroundStyle(brandColor) {
+ const color = String(brandColor || "").trim();
+ return CSS_HEX_COLOR.test(color) ? ` style="background:${color}22"` : "";
+}
+
+export function renderCatalogHtml(instanceId, catalog, { filter, category, source, config }, capabilityToken = "") {
+ const renderItem = (c) => {
+ // Items carry their home grid so hydrateState can move them into
+ // "My MCPs" when added and back to Microsoft/Partner on remove.
+ const home = c.category === CATEGORY.microsoft ? "microsoft" : "partner";
+ const icon = c.iconUri
+ ? ``
+ : `${esc(c.displayName.charAt(0))}
`;
+ // Button state is hydrated client-side from /api/state on load.
+ const btn = `${CONNECT_ICON}Create and connect `;
+ const haystack = esc((c.displayName + " " + (c.description || "")).toLowerCase());
+ const sandboxUrl = esc(buildSandboxUrl(config, c.apiName));
+ return ``;
+ };
+
+ const byName = (a, b) => a.displayName.localeCompare(b.displayName);
+ const microsoft = catalog.filter((c) => c.category === CATEGORY.microsoft).sort(byName);
+ const partner = catalog.filter((c) => c.category !== CATEGORY.microsoft).sort(byName);
+
+ const section = (key, title, rows, { collapsed, hidden }) => {
+ const cls = ["section", "collapsible"];
+ if (collapsed) cls.push("collapsed");
+ if (hidden) cls.push("is-hidden");
+ const n = rows.length;
+ return ``
+ + `
`
+ + ` `
+ + `${esc(title)} `
+ + `${n} `
+ + ` `
+ + ` `
+ + `
${rows.map(renderItem).join("")}
`
+ + `
`;
+ };
+
+ // Server paints the first-run layout: My MCPs hidden+empty (filled by
+ // hydrateState), Microsoft expanded so there's something to browse, Partner
+ // collapsed. updateSections() flips to the steady layout on the first hydrate
+ // if anything is already added.
+ let sectionsHtml =
+ section("mine", "My MCPs", [], { collapsed: false, hidden: true }) +
+ section("microsoft", "Microsoft", microsoft, { collapsed: false, hidden: microsoft.length === 0 }) +
+ section("partner", "Partners", partner, { collapsed: true, hidden: partner.length === 0 });
+
+ if (!catalog.length) {
+ sectionsHtml = `No connectors available.
`;
+ }
+
+ return `
+
+Connectors ${baseStyles()}
+
+
${brandMark(46, "ovl")}
+
+
+
+
${brandMark(24, "cat")}Connectors
+
+
Namespace ${esc(config.gatewayName)} · RG ${esc(config.resourceGroup)}
+
+
+
+ Switch namespace
+
+
+
+ Open in portal
+
+
+
+ Open config file
+
+
+
+
+
+
+
Restart your Copilot session to use newly added tools. Connectors are saved to your MCP config now, but their tools only load when a session starts.
+
Dismiss
+
+${sectionsHtml}
+
+`;
+}
+
+// ---------------------------------------------------------------------------
+// Error
+// ---------------------------------------------------------------------------
+
+export function renderErrorHtml(message) {
+ return `
+Error ${baseStyles()}
+
+${esc(message)}
+`;
+}
+
+// ---------------------------------------------------------------------------
+function esc(s) {
+ return String(s ?? "").replace(/[&<>"']/g, (c) => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
+}
diff --git a/extensions/connector-namespaces/renderer.test.mjs b/extensions/connector-namespaces/renderer.test.mjs
new file mode 100644
index 00000000..d0c464e4
--- /dev/null
+++ b/extensions/connector-namespaces/renderer.test.mjs
@@ -0,0 +1,393 @@
+// Regression guards for the connector-catalog renderer.
+//
+// Run: node --test extensions/connector-namespaces/renderer.test.mjs
+//
+// These tests exist because two UX bugs kept coming back:
+// 1. A `@media (prefers-reduced-motion: reduce)` rule froze functional
+// loaders without a visible fallback. Reduced motion now stops the
+// animation while forcing each loader into a visible static busy state;
+// nearby text continues to communicate progress.
+// 2. The "Restart your Copilot session" banner ignoring Dismiss. The real
+// root cause was CSS specificity: `.restart-banner{display:flex}` is an
+// author rule with the same (0,1,0) specificity as the UA
+// `[hidden]{display:none}` rule, so it overrode the hidden attribute and
+// `restartBanner.hidden=true` did nothing. The fix is a global
+// `[hidden]{display:none !important}` reset. A client-side
+// `restartDismissed` flag also keeps a late hydrateState() from re-showing
+// it. The guards below fail if either the CSS reset or the JS gate
+// disappears.
+
+import { test } from "node:test";
+import assert from "node:assert/strict";
+
+import { baseStyles, renderCatalogHtml, renderSetupHtml } from "./renderer.mjs";
+import { renderCreateNamespaceHtml } from "./createPage.mjs";
+import { CATEGORY } from "./categories.mjs";
+
+// Pull the balanced body of the prefers-reduced-motion media block out of a
+// stylesheet string (non-greedy regex can't handle the nested rule braces).
+// CSS comments are stripped so the guards test declarations rather than prose.
+function reducedMotionBlock(css) {
+ const start = css.indexOf("@media (prefers-reduced-motion: reduce)");
+ if (start === -1) return null;
+ const open = css.indexOf("{", start);
+ if (open === -1) return null;
+ let depth = 0;
+ for (let i = open; i < css.length; i++) {
+ if (css[i] === "{") depth++;
+ else if (css[i] === "}" && --depth === 0) {
+ return css.slice(open + 1, i).replace(/\/\*[\s\S]*?\*\//g, "");
+ }
+ }
+ return null;
+}
+
+function catalogHtml() {
+ return renderCatalogHtml("test-instance", [], {
+ filter: "",
+ category: "all",
+ source: "",
+ config: { subscriptionId: "sub", gatewayName: "ns", resourceGroup: "rg" },
+ });
+}
+
+test("setup subscription label names its select", () => {
+ const html = renderSetupHtml([], "", "token");
+ assert.match(html, /Subscription<\/label>/);
+});
+
+test("load-all and installed-state failures stay visible and fail closed", () => {
+ const setup = renderSetupHtml([], "", "token");
+ const catalog = catalogHtml();
+ assert.match(setup, /if \(data\.error\) throw new Error\(data\.error\)/, "Load all must enter its retryable failure path");
+ assert.match(setup, /if \(!await loadAll\(\)\) return/, "filtering must preserve the retry UI after Load all fails");
+ assert.match(
+ catalog,
+ /document\.querySelectorAll\("\.item-add"\)\.forEach\(button => \{ button\.disabled = true; \}\)/,
+ "actions must remain disabled until installed state is known",
+ );
+ assert.match(catalog, /Couldn't load connector state:/, "state failures must be shown to the user");
+});
+
+test("OAuth failures roll back only fresh connections and reconcile ambiguous finishes", () => {
+ const html = catalogHtml();
+ assert.match(html, /freshConnection = data\.freshConnection === true/);
+ assert.match(html, /if \(finishStarted && canReconcileFinish\)/, "only eligible finish failures may enter reconciliation");
+ assert.match(html, /reconcileFinishedInstall\(apiName, connName\)/);
+ assert.match(html, /!finishResponseReceived && complete/, "explicit finish errors must never be reclassified as success");
+ assert.match(html, /state\.connectionName === connName/, "ambiguous finishes must match the exact connection");
+ assert.match(html, /state\.connectionStatus === "Connected"/, "ambiguous finishes require a connected matching install");
+ assert.match(html, /if \(freshConnection && connName\)/, "definite pre-finish failures must clean up owned connections");
+ const reconciliation = html.slice(
+ html.indexOf("async function reconcileFinishedInstall"),
+ html.indexOf("async function recoverConnectorFailure"),
+ );
+ assert.doesNotMatch(reconciliation, /rollbackFreshConnection/, "ambiguous post-finish state must never delete a connection");
+ const connect = html.slice(html.indexOf("async function onConnect"), html.indexOf("async function onReauth"));
+ const reauth = html.slice(html.indexOf("async function onReauth"), html.indexOf("async function hydrateState"));
+ assert.match(connect, /finishResponseReceived,\s*true\s*\)/, "fresh Connect may reconcile an ambiguous finish");
+ assert.match(reauth, /finishResponseReceived,\s*freshConnection\s*\)/, "existing re-auth must not reuse its pre-existing state as proof of success");
+});
+
+test("failed install and re-auth requests always refresh fail-closed state", () => {
+ const html = catalogHtml();
+ const connectFailure = html.slice(html.indexOf("async function onConnect"), html.indexOf("async function onReauth"));
+ const reauthFailure = html.slice(html.indexOf("async function onReauth"), html.indexOf("async function hydrateState"));
+ assert.match(connectFailure, /await hydrateState\(\)/);
+ assert.match(reauthFailure, /await hydrateState\(\)/);
+ assert.doesNotMatch(html, /if \(cancelled \|\| finishStarted \|\| freshConnection\)/);
+ assert.match(connectFailure, /postIdempotentMutation\("\/api\/install"/);
+ assert.match(reauthFailure, /postIdempotentMutation\("\/api\/reauth"/);
+ assert.match(html, /for \(let attempt = 0; attempt < 2; attempt\+\+\)/, "ambiguous initial responses must replay the same request");
+ assert.match(html, /crypto\.getRandomValues\(bytes\)/, "every mutation needs a client-known replay id");
+});
+
+test("failed disconnect and namespace deletion refresh fail-closed state", () => {
+ const html = catalogHtml();
+ const disconnect = html.slice(html.indexOf("async function onRemoveLocal"), html.indexOf("function ensureDeleteDialog"));
+ const namespaceDelete = html.slice(html.indexOf("async function performNamespaceDelete"), html.indexOf("function waitForOAuth"));
+ assert.match(disconnect, /catch \(err\)[\s\S]*await hydrateState\(\)/);
+ assert.match(namespaceDelete, /catch \(err\)[\s\S]*await hydrateState\(\)/);
+ assert.doesNotMatch(disconnect, /mainBtn\.disabled = false/);
+ assert.doesNotMatch(namespaceDelete, /b\.disabled = false/);
+});
+
+test("baseStyles defines the spin keyframes", () => {
+ assert.match(baseStyles(), /@keyframes spin\b/, "spinner keyframes must be defined");
+});
+
+test("reduced-motion block replaces infinite loaders with static busy states", () => {
+ const block = reducedMotionBlock(baseStyles());
+ assert.ok(block, "a prefers-reduced-motion media block must exist");
+ assert.match(block, /\.brand-loading, \.skeleton, \.si-spin, \.spin/);
+ assert.match(block, /animation:\s*none\s*!important/);
+ assert.match(block, /border-top-color:\s*currentColor\s*!important/);
+ assert.match(block, /\.brand-loading, \.skeleton\s*\{\s*opacity:\s*1;\s*transform:\s*none/);
+});
+
+test("catalog keeps textual progress beside animated-default loaders", () => {
+ const html = catalogHtml();
+ assert.match(html, /\.si-spin\b[^}]*animation:\s*spin/, "install overlay spinner must use the spin animation");
+ assert.match(html, /currentColor[^"]*animation:\s*spin/, "the Connect button spinner must use the spin animation");
+ assert.match(html, /Connecting/, "the Connect button should show progress text alongside its spinner");
+});
+
+test("restart banner dismiss is sticky against a racing state refresh", () => {
+ const html = catalogHtml();
+ // The client-side dismissal flag and its gate in hydrateState must survive.
+ assert.match(html, /restartDismissed\s*=\s*true/, "dismiss handler must set the sticky flag");
+ assert.match(
+ html,
+ /restartBanner\.hidden\s*=\s*restartDismissed\s*\|\|\s*!d\.pendingRestart/,
+ "hydrateState must respect the dismissed flag so a late refresh can't re-show the banner",
+ );
+});
+
+test("a global [hidden] reset makes the hidden attribute authoritative", () => {
+ // The actual dismiss bug: .restart-banner{display:flex} (an author rule)
+ // ties the UA [hidden]{display:none} rule on specificity and wins, so the
+ // hidden attribute is ignored. This reset must exist or Dismiss silently
+ // breaks again, no matter how correct the JS is.
+ assert.match(
+ baseStyles() + catalogHtml(),
+ /\[hidden\]\s*\{[^}]*display:\s*none\s*!important/,
+ "a [hidden]{display:none !important} reset must exist so el.hidden=true actually hides",
+ );
+});
+
+test("the setup picker surfaces a fallback notice only when given one", () => {
+ // When an open-time catalog fetch fails (saved namespace deleted, access
+ // revoked, transient outage), the server falls back to the picker with a
+ // "pick another" notice instead of a dead-end error page. The notice must
+ // render when passed and stay absent otherwise.
+ const subs = [{ id: "sub-1-abcdef", name: "Sub One" }];
+ const withNotice = renderSetupHtml(subs, "couldn't open namespace ns .. pick another to continue.");
+ assert.match(withNotice, /class="setup-notice"/, "the picker must render the notice banner when one is supplied");
+ assert.match(withNotice, /pick another to continue/, "the notice copy must reach the page");
+
+ const plain = renderSetupHtml(subs);
+ assert.doesNotMatch(plain, /class="setup-notice"/, "no notice banner should render on a normal setup visit");
+});
+
+test("namespace choices are keyboard-accessible buttons", () => {
+ const html = renderSetupHtml([{ id: "sub-1", name: "Sub One" }]);
+ assert.match(html, / {
+ const setup = renderSetupHtml([{ id: "sub-1", name: "Sub One" }]);
+ assert.match(setup, /requestSeq !== gatewayRequestSeq \|\| subId !== subSelect\.value/);
+
+ const create = renderCreateNamespaceHtml([{ id: "sub-1", name: "Sub One" }]);
+ assert.match(create, /seq !== resourceGroupsSeq \|\| sub !== subSelect\.value/);
+ assert.match(create, /seq !== identitiesSeq \|\| sub !== subSelect\.value/);
+});
+
+test("create form invalidates pending checks immediately and uses unique identity ids", () => {
+ const html = renderCreateNamespaceHtml([{ id: "sub-1", name: "Sub One" }]);
+ assert.match(html, /clearTimeout\(nameTimer\);\s*checkSeq\+\+;/);
+ assert.match(html, /ids\.map\(\(id, index\) =>/);
+ assert.match(html, /id="uami-' \+ index/);
+});
+
+test("create form locks navigation and fields while provisioning", () => {
+ const html = renderCreateNamespaceHtml([{ id: "sub-1", name: "Sub One" }]);
+ assert.match(html, /for \(const control of document\.querySelectorAll\("button, input, select"\)\)/);
+ assert.match(html, /setFormLocked\(true\)/);
+ assert.match(html, /control\.disabled = true/);
+ assert.match(html, /document\.body\.setAttribute\("aria-busy"/);
+ assert.match(html, /finally \{\s*if \(creating\) setFormLocked\(true\)/);
+ assert.match(html, /if \(!creating\) window\.location\.href = "\/setup"/);
+ assert.match(html, /progress\.textContent = [^;]*request\.name/);
+});
+
+function catalogHtmlFull() {
+ // Non-empty fixture: one Microsoft item and one partner item, so the section
+ // partition, the move-model grids, and the collapsible heads can be asserted.
+ const catalog = [
+ { id: "p1", apiName: "acme", displayName: "Acme Widgets", description: "partner thing", iconUri: "", brandColor: "", category: CATEGORY.partner },
+ { id: "m1", apiName: "azureblob", displayName: "Azure Blob", description: "ms thing", iconUri: "", brandColor: "", category: CATEGORY.microsoft },
+ ];
+ return renderCatalogHtml("test-instance", catalog, {
+ filter: "",
+ category: "all",
+ source: "",
+ config: { subscriptionId: "sub", gatewayName: "ns", resourceGroup: "rg" },
+ });
+}
+
+test("the catalog renders three collapsible sections in order", () => {
+ const html = catalogHtmlFull();
+ const iMine = html.indexOf('id="sec-mine"');
+ const iMs = html.indexOf('id="sec-microsoft"');
+ const iPartner = html.indexOf('id="sec-partner"');
+ assert.ok(iMine !== -1 && iMs !== -1 && iPartner !== -1, "all three sections must render");
+ assert.ok(iMine < iMs && iMs < iPartner, "sections must render in order: mine, microsoft, partner");
+ assert.match(html, /id="sec-mine"[\s\S]*?>My MCPs, "mine section title");
+ assert.match(html, /id="sec-microsoft"[\s\S]*?>Microsoft, "microsoft section title");
+ assert.match(html, /id="sec-partner"[\s\S]*?>Partners, "partner section title");
+});
+
+test("Microsoft and partner items land in their home grids; My MCPs starts empty", () => {
+ const html = catalogHtmlFull();
+ // Server-rendered order is fixed (mine, microsoft, partner), so an item's
+ // index relative to the grid ids tells us which grid it sits in.
+ const iGridMs = html.indexOf('id="grid-microsoft"');
+ const iGridPartner = html.indexOf('id="grid-partner"');
+ const iMs = html.indexOf('data-api-item="azureblob"');
+ const iPartner = html.indexOf('data-api-item="acme"');
+ assert.ok(iMs > iGridMs && iMs < iGridPartner, "the Microsoft item must sit in #grid-microsoft");
+ assert.ok(iPartner > iGridPartner, "the partner item must sit in #grid-partner");
+ assert.match(html, /data-api-item="azureblob" data-home-grid="microsoft"/, "Microsoft item carries its home grid");
+ assert.match(html, /data-api-item="acme" data-home-grid="partner"/, "partner item carries its home grid");
+ assert.match(html, /id="grid-mine"[^>]*><\/div>/, "the My MCPs grid must be empty at render");
+});
+
+test("catalog only emits inline icon color for strict hex brand colors", () => {
+ const html = renderCatalogHtml("test-instance", [
+ {
+ id: "safe",
+ apiName: "safe",
+ displayName: "Safe",
+ description: "",
+ iconUri: "https://example.com/safe.png",
+ brandColor: "#5059c9",
+ category: CATEGORY.partner,
+ },
+ {
+ id: "bad",
+ apiName: "bad",
+ displayName: "Bad",
+ description: "",
+ iconUri: "https://example.com/bad.png",
+ brandColor: "#5059c9;background-image:url(https://attacker.example/pixel);color:",
+ category: CATEGORY.partner,
+ },
+ {
+ id: "short",
+ apiName: "short",
+ displayName: "Short",
+ description: "",
+ iconUri: "https://example.com/short.png",
+ brandColor: "#fff",
+ category: CATEGORY.partner,
+ },
+ {
+ id: "alpha",
+ apiName: "alpha",
+ displayName: "Alpha",
+ description: "",
+ iconUri: "https://example.com/alpha.png",
+ brandColor: "#5059c980",
+ category: CATEGORY.partner,
+ },
+ ], {
+ filter: "",
+ category: "all",
+ source: "",
+ config: { subscriptionId: "sub", gatewayName: "ns", resourceGroup: "rg" },
+ });
+
+ assert.match(html, /style="background:#5059c922"/, "valid hex colors should render as the icon background");
+ assert.doesNotMatch(html, /background-image/, "non-hex color payloads must not reach inline CSS");
+ assert.doesNotMatch(html, /attacker\.example/, "attacker-controlled CSS URLs must not render");
+ assert.doesNotMatch(html, /background:#fff22/, "shorthand colors cannot be combined with an appended alpha");
+ assert.doesNotMatch(html, /background:#5059c98022/, "colors that already contain alpha cannot append another alpha");
+});
+
+test("section heads are accessible toggle buttons", () => {
+ const html = catalogHtmlFull();
+ assert.match(
+ html,
+ / {
+ const html = catalogHtmlFull();
+ assert.doesNotMatch(html, /id="filter-bar"/, "the old filter bar must be removed");
+ assert.doesNotMatch(html, /Added \(/, "the old Added (N) pill copy must be gone");
+});
+
+test("the catalog header shows the active namespace and a switch-namespace button", () => {
+ // The header surfaces the active connector namespace in the sub line, and a
+ // "switch namespace" button in the gw-actions row is the switch affordance:
+ // clicking it returns to the /setup picker. So the page must show the
+ // namespace name and carry the nav to /setup.
+ const html = catalogHtml(); // fixture config.gatewayName = "ns"
+ assert.match(html, /class="cn-name">ns, "the header must show the active namespace name");
+ assert.match(html, /id="switch-ns"/, "the header must render the switch-namespace button");
+ assert.match(
+ html,
+ /id="switch-ns"[^>]*onclick="[^"]*window\.location\.href='\/setup'/,
+ "clicking switch namespace must navigate to the /setup picker",
+ );
+});
+
+test("My MCP hydration adds a per-server Sandbox deep link", () => {
+ const html = catalogHtmlFull();
+ assert.match(
+ html,
+ /data-sandbox-url="https:\/\/connectors\.azure\.com\/sub\/rg\/ns\/mcp-playground\?server=azureblob"/,
+ "each connector tile must carry its namespace Sandbox URL",
+ );
+ assert.match(html, /sandbox\.className = "item-add sandbox-btn item-icon-action"/, "installed My MCPs must get the Sandbox action");
+ assert.match(html, /sandbox\.title = "Open this MCP in Connector Namespace playground"/, "the Sandbox icon should describe the Connector Namespace playground");
+ assert.match(html, /sandbox\.setAttribute\("aria-label", "Open " \+ displayName \+ " in Connector Sandbox"\)/, "the icon-only action needs an accessible label");
+ assert.doesNotMatch(html, /Sandbox<\/span>/, "the compact action should show only the flask mark");
+ assert.ok(
+ html.indexOf('if (!st || !st.installed)') < html.indexOf('sandbox.className = "item-add sandbox-btn item-icon-action"'),
+ "the Sandbox action must be created only after the non-installed tile returns",
+ );
+});
+
+test("connect is compact and local disconnect appears only for local entries", () => {
+ const html = catalogHtmlFull();
+ const css = baseStyles();
+ assert.match(css, /\.item-add\.primary\s*\{[^}]*min-width:\s*62px;[^}]*font-size:\s*\.72rem;/, "Connect should use the compact primary-button sizing");
+ assert.match(html, /connectIcon \+ "Connect<\/span>"/, "Connect should include the plug mark before its label");
+ assert.match(html, /remove\.className = "item-add split-main item-icon-action"/, "local disconnect should be compact");
+ assert.match(html, /remove\.setAttribute\("aria-label", "Disconnect " \+ displayName \+ " from Copilot"\)/, "the icon needs a connector-specific label");
+ assert.match(html, /const disconnectIcon = '[^']*m2 2 12 12/, "the visible control should use a slashed plug mark");
+ assert.match(html, /remove\.innerHTML = disconnectIcon/, "the visible control should use the disconnect mark");
+ assert.match(html, /if \(st\.inCli\) \{\s*remove = document\.createElement\("button"\)/, "remote-only resources must not offer local disconnect");
+ assert.match(html, /if \(remove\) splitWrap\.appendChild\(remove\)/, "namespace delete options must remain available without a disconnect button");
+ assert.match(html, /\.split-remove \.split-main \{[^}]*color:var\(--danger\)/, "the disconnect icon should be red");
+ assert.match(html, /\.split-remove \.split-caret\s*\{[^}]*padding:\.2rem \.3rem/, "the destructive-options caret should stay narrow");
+ assert.match(html, /\.split-remove \.split-caret svg\s*\{[^}]*width:8px; height:8px;/, "the caret mark should match its smaller button");
+});
+
+test("My MCPs sorts fully connected entries before other installed resources", () => {
+ const html = catalogHtmlFull();
+ assert.match(
+ html,
+ /item\.dataset\.connectionReady = st\.connectionStatus === "Connected" && st\.inCli \? "1" : "0"/,
+ "hydration should mark resources that are connected and available in Copilot",
+ );
+ assert.match(
+ html,
+ /if \(grid\.id === "grid-mine"\)[\s\S]*Number\(b\.dataset\.connectionReady === "1"\) - Number\(a\.dataset\.connectionReady === "1"\)/,
+ "My MCPs should put fully connected entries first",
+ );
+});
+
+test("narrow connector cards keep the name above wrapped actions", () => {
+ const css = baseStyles();
+ assert.match(css, /@media \(max-width: 520px\)/, "catalog cards need a narrow-panel layout");
+ assert.match(
+ css,
+ /\.item\s*\{[^}]*grid-template-columns:\s*40px minmax\(0,\s*1fr\)/,
+ "narrow cards must preserve a real text column beside the icon",
+ );
+ assert.match(
+ css,
+ /\.item > \.item-add,\s*\.item > \.item-actions\s*\{[^}]*grid-row:\s*2/,
+ "actions must move below the connector name instead of squeezing it out",
+ );
+ assert.match(css, /\.item-actions\s*\{[^}]*flex-wrap:\s*wrap/, "narrow action rows must wrap");
+});
diff --git a/extensions/connector-namespaces/review-fixes.test.mjs b/extensions/connector-namespaces/review-fixes.test.mjs
new file mode 100644
index 00000000..60f17efc
--- /dev/null
+++ b/extensions/connector-namespaces/review-fixes.test.mjs
@@ -0,0 +1,139 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { mkdtemp, mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises";
+import { delimiter, join } from "node:path";
+import { tmpdir } from "node:os";
+
+import { armSegment, parseAzureCliToken, resolvePosixAzureCli, waitForProvisioning } from "./armClient.mjs";
+
+const here = new URL(".", import.meta.url);
+
+test("ARM path segments reject traversal aliases", () => {
+ assert.throws(() => armSegment("."), /Invalid ARM resource identifier/);
+ assert.throws(() => armSegment(".."), /Invalid ARM resource identifier/);
+ assert.equal(armSegment("valid.name"), "valid.name");
+});
+
+test("namespace creation is create-only and reports provisioning timeout", async () => {
+ const source = await readFile(new URL("armClient.mjs", here), "utf8");
+ const createResourceGroup = source.slice(
+ source.indexOf("export async function createResourceGroup"),
+ source.indexOf("export async function listUserAssignedIdentities"),
+ );
+ const createConnectorGateway = source.slice(
+ source.indexOf("export async function createConnectorGateway"),
+ );
+ assert.match(createResourceGroup, /"If-None-Match": "\*"/);
+ assert.match(createConnectorGateway, /"If-None-Match": "\*"/);
+ assert.match(source, /Provisioning timed out/);
+});
+
+test("namespace creation polls an empty 202 result until explicit success", async () => {
+ const states = [
+ { properties: { provisioningState: "InProgress" } },
+ { properties: { provisioningState: "Succeeded" } },
+ ];
+ let calls = 0;
+ const result = await waitForProvisioning(
+ undefined,
+ "gateway",
+ async () => {
+ calls++;
+ return states.shift();
+ },
+ { maxPolls: 2, delay: async () => {} },
+ );
+ assert.equal(calls, 2);
+ assert.equal(result.properties.provisioningState, "Succeeded");
+ await assert.rejects(
+ waitForProvisioning(undefined, "gateway", async () => undefined, { maxPolls: 1, delay: async () => {} }),
+ /last state: unknown/,
+ );
+});
+
+test("Azure authentication is brokered by Azure CLI", async () => {
+ const source = await readFile(new URL("armClient.mjs", here), "utf8");
+ assert.match(source, /account get-access-token --resource/);
+ assert.doesNotMatch(source, /04b07795-8ddb-461a-bbee-02f9e1bf7b46/);
+ assert.doesNotMatch(source, /refreshToken/);
+ assert.match(source, /await fs\.unlink\(LEGACY_AUTH_CACHE\)/);
+ assert.match(source, /resolveWindowsAzureCli/);
+ assert.match(source, /resolvePosixAzureCli/);
+ assert.match(source, /fs\.realpath\(path\)/);
+ assert.match(source, /windowsSystemExecutable\("cmd\.exe"\)/);
+ assert.match(source, /\{ cwd: homedir\(\), encoding: "utf8"/);
+ assert.deepEqual(
+ parseAzureCliToken(JSON.stringify({ accessToken: "token", expires_on: 2_000_000_000 })),
+ { token: "token", expiresAt: 2_000_000_000_000 },
+ );
+ assert.throws(() => parseAzureCliToken("{}"), /incomplete ARM token/);
+});
+
+test("POSIX Azure CLI resolution rejects workspace-controlled binaries", async () => {
+ const root = await mkdtemp(join(tmpdir(), "cn-az-path-"));
+ const workspace = join(root, "workspace");
+ const workspaceBin = join(workspace, "node_modules", ".bin");
+ const trustedBin = join(root, "trusted-bin");
+ await Promise.all([
+ mkdir(workspaceBin, { recursive: true }),
+ mkdir(trustedBin, { recursive: true }),
+ ]);
+ await Promise.all([
+ writeFile(join(workspaceBin, "az"), "workspace", { mode: 0o755 }),
+ writeFile(join(trustedBin, "az"), "trusted", { mode: 0o755 }),
+ ]);
+ try {
+ const resolved = await resolvePosixAzureCli(
+ [workspaceBin, trustedBin].join(delimiter),
+ workspace,
+ );
+ assert.equal(resolved, await realpath(join(trustedBin, "az")));
+ await assert.rejects(
+ resolvePosixAzureCli(workspaceBin, workspace),
+ /outside the current workspace/,
+ );
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+});
+
+test("installer preserves capability tokens and persists direct HTTP entries", async () => {
+ const source = await readFile(new URL("install.mjs", here), "utf8");
+ const fallbacks = source.match(/installConnector\(config, apiName, displayName, callbackBase, scope, capabilityToken\)/g);
+ assert.equal(fallbacks?.length, 1);
+ assert.match(source, /reauthConnectorWithAttempts\([\s\S]*?capabilityToken,[\s\S]*?attemptedConfigNames/);
+ assert.match(source, /headers: \{ "X-API-Key": key \}/);
+ assert.match(source, /const cacheKey = `\$\{sub\}:\$\{location\}:\$\{apiName\}:\$\{requireSwagger\}`/);
+ assert.match(source, /throwAfterCleanup\(error, \[\(\) => deleteConnection\(config, connName\)\]\)/);
+ assert.match(source, /freshConnection: true/);
+ assert.match(source, /freshConnection: false/);
+ assert.match(source, /await fs\.open\(lockPath, "wx", 0o600\)/);
+ assert.match(source, /await fs\.rename\(temporary, path\)/);
+ assert.match(source, /resolveSystemExecutable\("rundll32\.exe"\)/);
+});
+
+test("smoke cleanup runs from finally and reports cleanup failures", async () => {
+ const source = await readFile(new URL("test/smoke.mjs", here), "utf8");
+ assert.match(source, /finally \{\s*if \(record\.cleanup\)/);
+ assert.match(source, /record\.cleanupError/);
+ assert.match(source, /failed\.length > 0 \|\| orchestrationErrors\.length > 0/);
+ assert.match(source, /probe\.status === "passed"/);
+ assert.match(source, /probe\.status === "skipped"/);
+ assert.match(source, /mode: 0o700/);
+ assert.match(source, /mode: 0o600/);
+ assert.match(source, /chmodSync\(PENDING_FILE, 0o600\)/);
+});
+
+test("test reports do not persist successful tool response content", async () => {
+ const source = await readFile(new URL("test/mcp-probe.mjs", here), "utf8");
+ assert.doesNotMatch(source, /toolsCall\.preview\s*=/);
+ assert.match(source, /toolsCall\.result = "response received"/);
+});
+
+test("obsolete in-memory add and remove canvas actions are not advertised", async () => {
+ const source = await readFile(new URL("extension.mjs", here), "utf8");
+ assert.doesNotMatch(source, /name: "add_connector"/);
+ assert.doesNotMatch(source, /name: "remove_connector"/);
+ assert.doesNotMatch(source, /name: "list_connectors"/);
+ assert.match(source, /name: "open_sandbox"/);
+});
diff --git a/extensions/connector-namespaces/sandbox.mjs b/extensions/connector-namespaces/sandbox.mjs
new file mode 100644
index 00000000..029ec2b4
--- /dev/null
+++ b/extensions/connector-namespaces/sandbox.mjs
@@ -0,0 +1,49 @@
+const SANDBOX_ORIGIN = "https://connectors.azure.com";
+
+function requiredString(value, name) {
+ const text = String(value || "").trim();
+ if (!text) throw new Error(`missing ${name}`);
+ return text;
+}
+
+export function buildSandboxUrl(config, server) {
+ const subscriptionId = requiredString(config?.subscriptionId, "subscriptionId");
+ const resourceGroup = requiredString(config?.resourceGroup, "resourceGroup");
+ const gatewayName = requiredString(config?.gatewayName, "gatewayName");
+ const serverName = requiredString(server, "server");
+ const path = [subscriptionId, resourceGroup, gatewayName, "mcp-playground"]
+ .map(encodeURIComponent)
+ .join("/");
+ const url = new URL(`/${path}`, SANDBOX_ORIGIN);
+ url.searchParams.set("server", serverName);
+ return url.toString();
+}
+
+export function resolveSandboxConnector(catalog, installedState, query) {
+ const requested = requiredString(query, "server").toLowerCase();
+ const available = catalog
+ .filter((connector) => installedState[connector.apiName]?.installed)
+ .map((connector) => ({
+ id: connector.apiName,
+ displayName: connector.displayName,
+ }));
+
+ const exact = available.filter((connector) =>
+ connector.id.toLowerCase() === requested ||
+ connector.displayName.toLowerCase() === requested
+ );
+ if (exact.length === 1) return { connector: exact[0], available };
+
+ const partial = available.filter((connector) =>
+ connector.id.toLowerCase().includes(requested) ||
+ connector.displayName.toLowerCase().includes(requested)
+ );
+ if (partial.length === 1) return { connector: partial[0], available };
+
+ return {
+ connector: null,
+ reason: partial.length > 1 ? "ambiguous" : "not_found_in_my_mcps",
+ matches: partial,
+ available,
+ };
+}
diff --git a/extensions/connector-namespaces/sandbox.test.mjs b/extensions/connector-namespaces/sandbox.test.mjs
new file mode 100644
index 00000000..b5911fb9
--- /dev/null
+++ b/extensions/connector-namespaces/sandbox.test.mjs
@@ -0,0 +1,54 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+
+import { buildSandboxUrl, resolveSandboxConnector } from "./sandbox.mjs";
+
+const config = {
+ subscriptionId: "f34b22a3-2202-4fb1-b040-1332bd928c84",
+ resourceGroup: "jack sandboxgroup rg",
+ gatewayName: "yeah-github-cli",
+};
+
+const catalog = [
+ { apiName: "WorkIQTeamsMCP-1a81f9", displayName: "Work IQ Teams MCP" },
+ { apiName: "WorkIQSharePointMCP-abcd", displayName: "Work IQ SharePoint MCP" },
+ { apiName: "OtherMCP-1234", displayName: "Other MCP" },
+];
+
+const installedState = {
+ "WorkIQTeamsMCP-1a81f9": { installed: true },
+ "WorkIQSharePointMCP-abcd": { installed: true },
+ "OtherMCP-1234": { installed: false },
+};
+
+test("buildSandboxUrl creates the namespace playground deep link", () => {
+ assert.equal(
+ buildSandboxUrl(config, "WorkIQTeamsMCP-1a81f9"),
+ "https://connectors.azure.com/f34b22a3-2202-4fb1-b040-1332bd928c84/jack%20sandboxgroup%20rg/yeah-github-cli/mcp-playground?server=WorkIQTeamsMCP-1a81f9",
+ );
+});
+
+test("resolveSandboxConnector finds a My MCP by display-name fragment", () => {
+ const result = resolveSandboxConnector(catalog, installedState, "teams");
+ assert.deepEqual(result.connector, {
+ id: "WorkIQTeamsMCP-1a81f9",
+ displayName: "Work IQ Teams MCP",
+ });
+});
+
+test("resolveSandboxConnector never returns catalog entries outside My MCPs", () => {
+ const result = resolveSandboxConnector(catalog, installedState, "Other MCP");
+ assert.equal(result.connector, null);
+ assert.equal(result.reason, "not_found_in_my_mcps");
+ assert.deepEqual(result.available.map((connector) => connector.id), [
+ "WorkIQTeamsMCP-1a81f9",
+ "WorkIQSharePointMCP-abcd",
+ ]);
+});
+
+test("resolveSandboxConnector reports ambiguous names with matches", () => {
+ const result = resolveSandboxConnector(catalog, installedState, "work iq");
+ assert.equal(result.connector, null);
+ assert.equal(result.reason, "ambiguous");
+ assert.equal(result.matches.length, 2);
+});
diff --git a/extensions/connector-namespaces/server.mjs b/extensions/connector-namespaces/server.mjs
new file mode 100644
index 00000000..39f8eefd
--- /dev/null
+++ b/extensions/connector-namespaces/server.mjs
@@ -0,0 +1,616 @@
+// Loopback HTTP server — serves connector namespace picker + connector catalog UI.
+
+import { createServer } from "node:http";
+import { randomBytes } from "node:crypto";
+import { renderCatalogHtml, renderErrorHtml, renderSetupHtml } from "./renderer.mjs";
+import { renderCreateNamespaceHtml } from "./createPage.mjs";
+import { addConnector, removeConnector, saveConfig, clearConfig } from "./state.mjs";
+import { fetchCatalog, invalidateCache } from "./catalog.mjs";
+import {
+ listConnectorGateways,
+ listSubscriptions,
+ listResourceGroups,
+ listUserAssignedIdentities,
+ checkConnectorGatewayNameAvailable,
+ createResourceGroup,
+ createConnectorGateway,
+ buildGatewayIdentity,
+} from "./armClient.mjs";
+import { installConnector, finishInstall, reauthConnector, finishReauth, openInBrowser, openMcpConfigFile, getInstalledState, uninstallConnector, removeLocalEntry, deleteConnection, prewarmMeta } from "./install.mjs";
+
+const servers = new Map();
+const starting = new Map(); // instanceId → Promise while a server is binding
+const gatewayCache = new Map();
+const pendingOAuth = new Map(); // connName → timestamp
+
+const PENDING_OAUTH_TTL_MS = 15 * 60 * 1000;
+const MUTATION_REPLAY_TTL_MS = 15 * 60 * 1000;
+const CAPABILITY_TOKEN_HEADER = "x-connector-namespace-token";
+const MAX_REQUEST_BODY_BYTES = 64 * 1024;
+const REQUEST_ID = /^[0-9a-f]{32}$/;
+
+class RequestBodyTooLargeError extends Error {}
+
+function createCapabilityToken() {
+ return randomBytes(32).toString("base64url");
+}
+
+export function isCanonicalHost(req, canonicalHost) {
+ return String(req.headers.host || "").toLowerCase() === String(canonicalHost || "").toLowerCase();
+}
+
+export function requiresCapabilityToken(pathname) {
+ return pathname.startsWith("/api/") || pathname === "/oauth-status" || pathname.startsWith("/auth/callback/");
+}
+
+export function hasCapabilityToken(req, url, expectedToken) {
+ if (!expectedToken) {
+ return false;
+ }
+ const header = req.headers[CAPABILITY_TOKEN_HEADER];
+ const headerToken = Array.isArray(header) ? header[0] : header;
+ const callbackToken = url.pathname.startsWith("/auth/callback/")
+ ? url.searchParams.get("cn_token")
+ : null;
+ return headerToken === expectedToken || callbackToken === expectedToken;
+}
+
+function rejectForbidden(res, error) {
+ res.statusCode = 403;
+ res.setHeader("Content-Type", "application/json");
+ res.end(JSON.stringify({ error }));
+}
+
+// Drop stale/abandoned consent markers so the map can't grow unbounded and a
+// brand-new install of the same connection can't observe a stale "done".
+function prunePendingOAuth() {
+ const now = Date.now();
+ for (const [name, ts] of pendingOAuth) {
+ if (now - ts > PENDING_OAUTH_TTL_MS) pendingOAuth.delete(name);
+ }
+}
+
+export function runIdempotentOperation(operations, key, start, now = Date.now()) {
+ for (const [id, operation] of operations) {
+ if (operation.expiresAt <= now) operations.delete(id);
+ }
+ const existing = operations.get(key);
+ if (existing) return existing.promise;
+ const promise = Promise.resolve().then(start);
+ operations.set(key, { promise, expiresAt: now + MUTATION_REPLAY_TTL_MS });
+ return promise;
+}
+
+// Whether a connector was added during the life of THIS extension process.
+// MCP tools are only loaded by the CLI at session start, so an install done
+// after the process started isn't usable until the session restarts. A real
+// session restart spawns a fresh process and resets this to false. `acked`
+// lets the user dismiss the reminder for the rest of the process.
+let pendingRestart = false;
+let restartAcked = false;
+
+export function parseBody(req) {
+ return new Promise((resolve, reject) => {
+ let data = "";
+ let size = 0;
+ let settled = false;
+ req.on("data", (chunk) => {
+ if (settled) return;
+ size += Buffer.byteLength(chunk);
+ if (size > MAX_REQUEST_BODY_BYTES) {
+ settled = true;
+ data = "";
+ reject(new RequestBodyTooLargeError());
+ return;
+ }
+ data += chunk;
+ });
+ req.on("end", () => {
+ if (settled) return;
+ settled = true;
+ try { resolve(JSON.parse(data)); }
+ catch { resolve({}); }
+ });
+ });
+}
+
+// Blocks cross-site POSTs to /api/* so a random web page can't drive the user's
+// ARM operations through this loopback server (CSRF). Returns true only when the
+// request carries an explicit foreign-origin signal: an Origin header from a
+// different http(s) origin than the canonical loopback URL, an opaque `null`
+// origin (sandboxed iframe / data: or blob: URI), or a Fetch-Metadata marker of
+// cross-site/same-site. The panel loads as a top-level http document, so its own
+// fetches are same-origin (Origin === our canonical loopback origin, never
+// "null"), and header-less callers (the node test harness, non-browser clients)
+// fall through as allowed, so nothing legit breaks.
+export function isCrossSiteRequest(req, canonicalOrigin) {
+ const origin = req.headers.origin;
+ if (origin) {
+ if (origin === canonicalOrigin) return false; // our own loopback UI
+ if (origin === "null") return true; // opaque origin: sandboxed iframe / data: or blob: URI
+ if (/^https?:\/\//i.test(origin)) return true; // a different web page
+ return false; // non-web scheme (host webview)
+ }
+ const site = req.headers["sec-fetch-site"];
+ return site === "cross-site" || site === "same-site";
+}
+
+async function handleRequest(req, res, instanceId, serverEntry) {
+ const url = new URL(req.url, "http://localhost");
+
+ if (!isCanonicalHost(req, serverEntry.host)) {
+ rejectForbidden(res, "host_not_allowed");
+ return;
+ }
+
+ if (requiresCapabilityToken(url.pathname) && !hasCapabilityToken(req, url, serverEntry.token)) {
+ rejectForbidden(res, "missing_or_invalid_capability_token");
+ return;
+ }
+
+ // Reject cross-site attempts to invoke state-changing API routes (CSRF).
+ // Only POST /api/* is gated: GET navigations like the OAuth callback and the
+ // read routes are never blocked here.
+ if (req.method === "POST" && url.pathname.startsWith("/api/") && isCrossSiteRequest(req, serverEntry.origin)) {
+ rejectForbidden(res, "cross_site_blocked");
+ return;
+ }
+
+ // --- API routes ---
+
+ if (req.method === "POST" && url.pathname === "/api/add") {
+ const body = await parseBody(req);
+ const config = serverEntry.config;
+ if (!config) { json(res, { added: false, reason: "no_config" }); return; }
+ const catalog = await fetchCatalog(config.subscriptionId, config.resourceGroup, config.gatewayName);
+ const connector = catalog.find((c) => c.id === body.id);
+ json(res, connector ? addConnector(connector) : { added: false, reason: "not_found" });
+ return;
+ }
+
+ if (req.method === "POST" && url.pathname === "/api/remove") {
+ const body = await parseBody(req);
+ json(res, removeConnector(body.id));
+ return;
+ }
+
+ if (req.method === "POST" && url.pathname === "/api/select-gateway") {
+ const body = await parseBody(req);
+ const { subscriptionId, resourceGroup, gatewayName } = body;
+ if (!subscriptionId || !resourceGroup || !gatewayName) {
+ json(res, { error: "missing_fields" });
+ return;
+ }
+ const config = { subscriptionId, resourceGroup, gatewayName };
+ serverEntry.config = config;
+ saveConfig(config);
+ invalidateCache();
+ json(res, { ok: true });
+ return;
+ }
+
+ if (req.method === "POST" && url.pathname === "/api/change-gateway") {
+ try {
+ clearConfig();
+ serverEntry.config = null;
+ invalidateCache();
+ json(res, { ok: true });
+ } catch (err) {
+ json(res, { error: err.message });
+ }
+ return;
+ }
+
+ if (req.method === "GET" && url.pathname === "/api/gateways") {
+ const subId = url.searchParams.get("subscriptionId");
+ const all = url.searchParams.get("all") === "true";
+ if (!subId) { json(res, { error: "missing subscriptionId" }); return; }
+ const cacheKey = `gw:${subId}:${all ? "all" : "top"}`;
+ if (gatewayCache.has(cacheKey)) {
+ const cached = gatewayCache.get(cacheKey);
+ json(res, { gateways: cached.items, hasMore: cached.hasMore, cached: true });
+ return;
+ }
+ try {
+ const result = await listConnectorGateways(subId, { fetchAll: all });
+ gatewayCache.set(cacheKey, result);
+ json(res, { gateways: result.items, hasMore: result.hasMore });
+ } catch (err) {
+ json(res, { error: err.message });
+ }
+ return;
+ }
+
+ // --- Create connector namespace routes ---
+
+ if (req.method === "GET" && url.pathname === "/api/resource-groups") {
+ const subId = url.searchParams.get("subscriptionId");
+ if (!subId) { json(res, { error: "missing subscriptionId" }); return; }
+ try {
+ json(res, { resourceGroups: await listResourceGroups(subId) });
+ } catch (err) {
+ json(res, { error: err.message });
+ }
+ return;
+ }
+
+ if (req.method === "GET" && url.pathname === "/api/identities") {
+ const subId = url.searchParams.get("subscriptionId");
+ if (!subId) { json(res, { error: "missing subscriptionId" }); return; }
+ try {
+ json(res, { identities: await listUserAssignedIdentities(subId) });
+ } catch (err) {
+ json(res, { error: err.message });
+ }
+ return;
+ }
+
+ if (req.method === "GET" && url.pathname === "/api/check-name") {
+ const subId = url.searchParams.get("subscriptionId");
+ const resourceGroup = url.searchParams.get("resourceGroup");
+ const name = url.searchParams.get("name");
+ if (!subId || !resourceGroup || !name) { json(res, { error: "missing_fields" }); return; }
+ try {
+ json(res, { available: await checkConnectorGatewayNameAvailable(subId, resourceGroup, name) });
+ } catch (err) {
+ json(res, { error: err.message });
+ }
+ return;
+ }
+
+ if (req.method === "POST" && url.pathname === "/api/create-namespace") {
+ const body = await parseBody(req);
+ const { subscriptionId, resourceGroup, createNewResourceGroup, region, name, enableSystemIdentity, userAssignedIds } = body;
+ if (!subscriptionId || !resourceGroup || !region || !name) {
+ json(res, { error: "missing_fields" });
+ return;
+ }
+ try {
+ if (createNewResourceGroup) {
+ await createResourceGroup(subscriptionId, resourceGroup, region);
+ }
+ const identity = buildGatewayIdentity(!!enableSystemIdentity, Array.isArray(userAssignedIds) ? userAssignedIds : []);
+ await createConnectorGateway(subscriptionId, resourceGroup, name, { location: region, identity });
+ const config = { subscriptionId, resourceGroup, gatewayName: name };
+ serverEntry.config = config;
+ saveConfig(config);
+ invalidateCache();
+ gatewayCache.clear();
+ json(res, { ok: true });
+ } catch (err) {
+ json(res, { error: err.message });
+ }
+ return;
+ }
+
+ // --- Install flow routes ---
+
+ if (req.method === "POST" && url.pathname === "/api/install") {
+ const body = await parseBody(req);
+ const config = serverEntry.config;
+ if (!config) { json(res, { error: "no_config" }); return; }
+ const { apiName, displayName, requestId } = body;
+ if (!apiName) { json(res, { error: "missing apiName" }); return; }
+ if (!REQUEST_ID.test(requestId || "")) { json(res, { error: "invalid requestId" }); return; }
+ const port = new URL(serverEntry.url).port;
+ const callbackBase = `http://127.0.0.1:${port}/auth/callback/`;
+ try {
+ const result = await runIdempotentOperation(
+ serverEntry.operations,
+ `install:${requestId}`,
+ () => installConnector(config, apiName, displayName || apiName, callbackBase, "profile", serverEntry.token),
+ );
+ if (result && !result.error && !result.needsConsent) { pendingRestart = true; restartAcked = false; }
+ json(res, result);
+ } catch (err) {
+ json(res, { error: err.message });
+ }
+ return;
+ }
+
+ if (req.method === "POST" && url.pathname === "/api/reauth") {
+ const body = await parseBody(req);
+ const config = serverEntry.config;
+ if (!config) { json(res, { error: "no_config" }); return; }
+ const { apiName, displayName, requestId } = body;
+ if (!apiName) { json(res, { error: "missing apiName" }); return; }
+ if (!REQUEST_ID.test(requestId || "")) { json(res, { error: "invalid requestId" }); return; }
+ const port = new URL(serverEntry.url).port;
+ const callbackBase = `http://127.0.0.1:${port}/auth/callback/`;
+ try {
+ const result = await runIdempotentOperation(
+ serverEntry.operations,
+ `reauth:${requestId}`,
+ () => reauthConnector(config, apiName, displayName || apiName, callbackBase, "profile", serverEntry.token),
+ );
+ if (result && !result.error && !result.needsConsent) { pendingRestart = true; restartAcked = false; }
+ json(res, result);
+ } catch (err) {
+ json(res, { error: err.message });
+ }
+ return;
+ }
+
+ if (req.method === "POST" && url.pathname === "/api/finish-install") {
+ const body = await parseBody(req);
+ const config = serverEntry.config;
+ if (!config) { json(res, { error: "no_config" }); return; }
+ if (!body.apiName) { json(res, { error: "missing apiName" }); return; }
+ if (!body.connName) { json(res, { error: "missing connName" }); return; }
+ try {
+ const result = await finishInstall(config, body.apiName, body.displayName, body.connName, body.location, "profile");
+ if (result && !result.error) { pendingRestart = true; restartAcked = false; }
+ json(res, result);
+ } catch (err) {
+ json(res, { error: err.message });
+ }
+ return;
+ }
+
+ if (req.method === "POST" && url.pathname === "/api/finish-reauth") {
+ const body = await parseBody(req);
+ const config = serverEntry.config;
+ if (!config) { json(res, { error: "no_config" }); return; }
+ if (!body.apiName) { json(res, { error: "missing apiName" }); return; }
+ if (!body.connName) { json(res, { error: "missing connName" }); return; }
+ try {
+ // configName may be absent when reauth fell back to a fresh install
+ // (stored connection was gone); finishReauth handles that defensively.
+ const result = await finishReauth(config, body.apiName, body.displayName, body.connName, body.configName, body.location, "profile");
+ if (result && !result.error) { pendingRestart = true; restartAcked = false; }
+ json(res, result);
+ } catch (err) {
+ json(res, { error: err.message });
+ }
+ return;
+ }
+
+ if (req.method === "POST" && url.pathname === "/api/open-url") {
+ const body = await parseBody(req);
+ if (!body.url || !/^https?:\/\//.test(body.url)) { json(res, { error: "invalid url" }); return; }
+ try {
+ await openInBrowser(body.url);
+ json(res, { ok: true });
+ } catch (err) {
+ json(res, { error: err.message });
+ }
+ return;
+ }
+
+ if (req.method === "POST" && url.pathname === "/api/open-config") {
+ try {
+ const result = await openMcpConfigFile();
+ json(res, result);
+ } catch (err) {
+ json(res, { ok: false, error: err.message });
+ }
+ return;
+ }
+
+ if (req.method === "GET" && url.pathname === "/oauth-status") {
+ prunePendingOAuth();
+ const connName = url.searchParams.get("connectionName") || "";
+ // Consume the marker once observed so the map self-cleans on the happy
+ // path instead of lingering until the TTL sweep. delete() returns true
+ // iff the marker existed, which is exactly the "done" signal.
+ const done = connName ? pendingOAuth.delete(connName) : false;
+ json(res, { done });
+ return;
+ }
+
+ if (req.method === "GET" && url.pathname === "/api/state") {
+ const config = serverEntry.config;
+ if (!config) { json(res, { error: "no_config" }); return; }
+ try {
+ const state = await getInstalledState(config);
+ json(res, { state, pendingRestart: pendingRestart && !restartAcked });
+ } catch (err) {
+ json(res, { error: err.message });
+ }
+ return;
+ }
+
+ if (req.method === "POST" && url.pathname === "/api/ack-restart") {
+ restartAcked = true;
+ json(res, { ok: true });
+ return;
+ }
+
+ if (req.method === "POST" && url.pathname === "/api/uninstall") {
+ const body = await parseBody(req);
+ const config = serverEntry.config;
+ if (!config) { json(res, { error: "no_config" }); return; }
+ if (!body.apiName) { json(res, { error: "missing apiName" }); return; }
+ try {
+ const result = await uninstallConnector(config, body.apiName);
+ json(res, result);
+ } catch (err) {
+ json(res, { error: err.message });
+ }
+ return;
+ }
+
+ // Local-only remove: unwire the connector from Copilot's mcp config without
+ // deleting anything on the namespace. Mirrors /api/uninstall but calls the
+ // local-only primitive.
+ if (req.method === "POST" && url.pathname === "/api/remove-local") {
+ const body = await parseBody(req);
+ const config = serverEntry.config;
+ if (!config) { json(res, { error: "no_config" }); return; }
+ if (!body.apiName) { json(res, { error: "missing apiName" }); return; }
+ try {
+ const result = await removeLocalEntry(config, body.apiName);
+ json(res, result);
+ } catch (err) {
+ json(res, { error: err.message });
+ }
+ return;
+ }
+
+ // Roll back a connection orphaned by a cancelled install (no config exists
+ // yet, so /api/uninstall can't find it — delete the connection directly).
+ if (req.method === "POST" && url.pathname === "/api/rollback-connection") {
+ const body = await parseBody(req);
+ const config = serverEntry.config;
+ if (!config) { json(res, { error: "no_config" }); return; }
+ if (!body.connName) { json(res, { error: "missing connName" }); return; }
+ try {
+ const result = await deleteConnection(config, body.connName);
+ json(res, result);
+ } catch (err) {
+ json(res, { error: err.message });
+ }
+ return;
+ }
+
+ if (req.method === "GET" && url.pathname.startsWith("/auth/callback/")) {
+ const connName = decodeURIComponent(url.pathname.slice("/auth/callback/".length));
+ prunePendingOAuth();
+ if (connName) pendingOAuth.set(connName, Date.now());
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
+ res.end(`Sign-in complete Sign-in complete You can close this tab and return to Copilot.
`);
+ return;
+ }
+
+ // --- Page routes ---
+
+ const config = serverEntry.config;
+
+ // Create connector namespace page (reachable with or without a saved config)
+ if (url.pathname === "/create") {
+ try {
+ const subs = await listSubscriptions();
+ const preselected = url.searchParams.get("subscriptionId") || "";
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
+ res.end(renderCreateNamespaceHtml(subs, preselected, serverEntry.token));
+ } catch (err) {
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
+ res.end(renderErrorHtml(`Failed to load subscriptions. Run az login in a terminal, then reload this page.\n\n${err.message}`));
+ }
+ return;
+ }
+
+ // Setup page (no gateway configured)
+ if (!config || url.pathname === "/setup") {
+ try {
+ const subs = await listSubscriptions();
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
+ res.end(renderSetupHtml(subs, "", serverEntry.token));
+ } catch (err) {
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
+ res.end(renderErrorHtml(`Failed to load subscriptions. Run az login in a terminal, then reload this page.\n\n${err.message}`));
+ }
+ return;
+ }
+
+ // Catalog page
+ const filter = url.searchParams.get("filter") || "";
+ const category = url.searchParams.get("category") || "";
+ const source = url.searchParams.get("source") || "";
+
+ try {
+ const catalog = await fetchCatalog(config.subscriptionId, config.resourceGroup, config.gatewayName);
+ // Warm connector metadata (opId + connection params) in the background so
+ // the Connect click doesn't pay for the slow swagger export.
+ prewarmMeta(config, catalog.map((c) => c.apiName));
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
+ res.end(renderCatalogHtml(instanceId, catalog, { filter, category, source, config }, serverEntry.token));
+ } catch (err) {
+ // Trust-and-fallback: a saved namespace that can no longer be read
+ // (deleted, access revoked, a transient outage) should drop the user
+ // back to the picker, not a dead-end error page. Only if even the
+ // picker can't load its subscriptions do we surface the raw error.
+ try {
+ const subs = await listSubscriptions();
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
+ res.end(renderSetupHtml(subs, `couldn't open namespace ${config.gatewayName} .. pick another to continue.`, serverEntry.token));
+ } catch (subErr) {
+ // Both the namespace catalog and the subscription list failed. Surface
+ // the subscription error (the reason the picker itself can't render) and
+ // keep the original namespace error for context.
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
+ res.end(renderErrorHtml(`couldn't load subscriptions: ${subErr.message} .. opening namespace ${config.gatewayName} also failed: ${err.message}`));
+ }
+ }
+}
+
+function json(res, data) {
+ res.setHeader("Content-Type", "application/json");
+ res.end(JSON.stringify(data));
+}
+
+export function getServerConfig(instanceId) {
+ return servers.get(instanceId)?.config ?? null;
+}
+
+export function listenOnLoopback(server) {
+ return new Promise((resolve, reject) => {
+ const onError = (error) => reject(error);
+ server.once("error", onError);
+ server.listen(0, "127.0.0.1", () => {
+ server.removeListener("error", onError);
+ resolve();
+ });
+ });
+}
+
+export function startServer(instanceId, { config, defaultConfig } = {}) {
+ const existing = servers.get(instanceId);
+ if (existing) {
+ if (config !== undefined) existing.config = config;
+ return Promise.resolve(existing);
+ }
+ const inflight = starting.get(instanceId);
+ if (inflight) return inflight;
+
+ const p = (async () => {
+ const entry = {
+ server: null,
+ url: "",
+ host: "",
+ origin: "",
+ token: createCapabilityToken(),
+ config: config ?? defaultConfig ?? null,
+ operations: new Map(),
+ };
+ const server = createServer((req, res) => {
+ handleRequest(req, res, instanceId, entry).catch((err) => {
+ if (res.writableEnded) return;
+ res.statusCode = err instanceof RequestBodyTooLargeError ? 413 : 500;
+ if (!(err instanceof RequestBodyTooLargeError)) {
+ console.error("[connector-namespaces] request failed:", err);
+ }
+ json(res, { error: err instanceof RequestBodyTooLargeError ? "request_body_too_large" : "internal_error" });
+ });
+ });
+ entry.server = server;
+ await listenOnLoopback(server);
+ const address = server.address();
+ const port = typeof address === "object" && address ? address.port : 0;
+ entry.host = `127.0.0.1:${port}`;
+ entry.origin = `http://${entry.host}`;
+ entry.url = `${entry.origin}/`;
+ servers.set(instanceId, entry);
+ return entry;
+ })();
+ // Record the in-flight start synchronously so a concurrent open() for the
+ // same instance awaits this server instead of binding a second one and
+ // leaking the first.
+ starting.set(instanceId, p);
+ const clearStarting = () => { if (starting.get(instanceId) === p) starting.delete(instanceId); };
+ p.then(clearStarting, clearStarting);
+ return p;
+}
+
+export async function stopServer(instanceId) {
+ const inflight = starting.get(instanceId);
+ if (inflight) { try { await inflight; } catch { /* start failed; nothing to close */ } }
+ const entry = servers.get(instanceId);
+ if (entry) {
+ servers.delete(instanceId);
+ // close() never resolves while the iframe holds a keep-alive socket —
+ // drop live connections first so onClose can't hang and leak the process.
+ if (typeof entry.server.closeAllConnections === "function") entry.server.closeAllConnections();
+ await new Promise((resolve) => entry.server.close(() => resolve()));
+ }
+}
diff --git a/extensions/connector-namespaces/server.test.mjs b/extensions/connector-namespaces/server.test.mjs
new file mode 100644
index 00000000..2c7fdcc6
--- /dev/null
+++ b/extensions/connector-namespaces/server.test.mjs
@@ -0,0 +1,215 @@
+// Guards for the cross-site request gate on the loopback API server.
+//
+// Run: node --test extensions/connector-namespaces/server.test.mjs
+//
+// The server binds an ephemeral 127.0.0.1 port and JSON-parses every POST body,
+// so without a check any web page the user visits could script-drive their ARM
+// operations (CSRF). isCrossSiteRequest is the gate: it blocks a POST /api/*
+// only when the request carries an explicit foreign-origin signal, and lets the
+// panel's own same-origin fetches — and header-less callers like this test
+// harness — through untouched. Importing server.mjs has no side effects at eval;
+// the HTTP server only starts when startServer() is called.
+
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { EventEmitter } from "node:events";
+import { Readable } from "node:stream";
+
+import {
+ getServerConfig,
+ hasCapabilityToken,
+ isCanonicalHost,
+ isCrossSiteRequest,
+ listenOnLoopback,
+ parseBody,
+ requiresCapabilityToken,
+ runIdempotentOperation,
+ startServer,
+ stopServer,
+} from "./server.mjs";
+import { isValidConfig } from "./state.mjs";
+
+// Minimal request stub: only headers matter to the gate.
+function req(headers) {
+ return { headers };
+}
+
+test("same-origin Origin (our own loopback UI) is allowed", () => {
+ const r = req({ host: "127.0.0.1:54321", origin: "http://127.0.0.1:54321" });
+ assert.equal(isCrossSiteRequest(r, "http://127.0.0.1:54321"), false);
+});
+
+test("no headers at all (test harness / non-browser client) is allowed", () => {
+ assert.equal(isCrossSiteRequest(req({}), "http://127.0.0.1:54321"), false);
+});
+
+test("non-web-scheme Origin (host webview) is allowed", () => {
+ // Some app webviews send Origin like "vscode-webview://..." or "app://..."
+ // custom schemes. Those aren't a browsable web page driving a CSRF, so we
+ // don't block them; only http(s) foreign origins and opaque `null` origins
+ // are treated as hostile.
+ const r = req({ host: "127.0.0.1:54321", origin: "app://obsidian.md" });
+ assert.equal(isCrossSiteRequest(r, "http://127.0.0.1:54321"), false);
+});
+
+test("opaque null Origin (sandboxed iframe / data: URI) is blocked", () => {
+ // Browsers send the literal string "null" as Origin from sandboxed iframes
+ // (