diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json
index 15f6192bc..16f5a82f8 100644
--- a/.github/plugin/marketplace.json
+++ b/.github/plugin/marketplace.json
@@ -247,12 +247,6 @@
"description": "A visual orb that users can ask the agent to recolor while showing a live activity log in the canvas.",
"version": "1.0.2"
},
- {
- "name": "connector-namespaces",
- "source": "extensions/connector-namespaces",
- "description": "Interactive GitHub Copilot canvas for discovering, connecting, and managing hosted MCP servers from Azure Connector Namespace.",
- "version": "1.2.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
deleted file mode 100644
index e9fbc015f..000000000
--- a/extensions/connector-namespaces/.github/plugin/plugin.json
+++ /dev/null
@@ -1,19 +0,0 @@
-{
- "name": "connector-namespaces",
- "description": "Interactive GitHub Copilot canvas for discovering, connecting, and managing hosted MCP servers from Azure Connector Namespace.",
- "version": "1.2.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
deleted file mode 100644
index 22aed37e6..000000000
--- a/extensions/connector-namespaces/LICENSE
+++ /dev/null
@@ -1,21 +0,0 @@
-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
deleted file mode 100644
index 66c534890..000000000
--- a/extensions/connector-namespaces/README.md
+++ /dev/null
@@ -1,81 +0,0 @@
-# MCP Connectors
-
-A GitHub Copilot app canvas extension for discovering and connecting hosted MCP
-servers from [Azure Connector Namespace](https://learn.microsoft.com/en-us/azure/connector-namespace/connector-namespace-overview).
-It brings the Microsoft and partner connector catalog, guided browser sign-in,
-and connected-server management into the Copilot side panel.
-
-## Features
-
-- **Connector catalog** - browse and search Microsoft and partner MCP servers
- available in your namespace.
-- **Guided Azure setup** - sign in from the canvas, then choose a subscription
- and Connector Namespace.
-- **Browser-based connection flow** - complete each connector's authentication
- or consent without leaving the setup experience.
-- **My MCPs** - see which servers are connected and ready to add to Copilot.
-- **Namespace playground** - open any connected server in the Connector
- Namespace playground with **Sandbox**.
-- **Persistent namespace selection** - retain the selected namespace while Azure
- tokens remain in memory and sign-in is requested again after a restart.
-
-## Install
-
-Open the GitHub Copilot app, go to **Settings > Plugins**, search for
-`connector-namespaces`, and select **Install**.
-
-You can also open the
-[MCP Connectors gallery page](https://awesome-copilot.github.com/extension/connector-namespaces/)
-and select **Install in GitHub Copilot app**.
-
-## Requirements
-
-- Access to an Azure subscription with a Connector Namespace. If you do not
- have one, follow the
- [Connector Namespace creation guide](https://learn.microsoft.com/en-us/azure/connector-namespace/create-connector-namespace).
-- Permission to view the namespace and create its connections and hosted MCP
- server configurations.
-- A browser for Microsoft Entra sign-in and connector consent.
-
-Connector Namespace is currently an Azure preview service and availability can
-vary by region.
-
-## Usage
-
-1. Open the **MCP Connectors** canvas in the GitHub Copilot app.
-2. Select **Sign in to Azure** and complete Microsoft Entra authentication in
- your browser.
-3. Choose an Azure subscription and Connector Namespace.
-4. Browse or search the catalog, then select **Connect** on an MCP server.
-5. Complete the connector's sign-in or consent flow when prompted.
-6. Confirm the server appears under **My MCPs**.
-7. Restart the GitHub Copilot app so the new tools become available to the
- agent.
-
-Use **Sandbox** on a connected server to inspect it in the Connector Namespace
-playground. Use **Change namespace** to switch subscriptions or namespaces.
-
-## Authentication and privacy
-
-Azure sign-in and connector sign-in are separate:
-
-- **Azure sign-in** lets the canvas discover and manage Connector Namespace
- resources. Access and refresh tokens remain in the extension process and are
- never written to extension files. Reloading the extension or restarting the
- app requires Azure sign-in again. The selected namespace coordinates are
- retained in
- `~/.copilot/extensions/connector-namespaces/artifacts/gateway-config.json` so
- the canvas can explain that the namespace is still linked and return directly
- to its connectors after sign-in.
-- **Connector sign-in** grants an individual MCP server access to its backing
- service. The resulting connection is managed by Connector Namespace.
-
-The canvas serves its interface from loopback only (`127.0.0.1`). Azure
-management requests are restricted to `https://management.azure.com/`.
-The gateway API key that lets Copilot reach a connected server is stored in the
-user-scoped GitHub Copilot MCP configuration and sent only to that server's
-configured HTTPS endpoint.
-
-## License
-
-[MIT](./LICENSE) © Microsoft Corporation.
diff --git a/extensions/connector-namespaces/armClient.mjs b/extensions/connector-namespaces/armClient.mjs
deleted file mode 100644
index c4ef7f4a5..000000000
--- a/extensions/connector-namespaces/armClient.mjs
+++ /dev/null
@@ -1,345 +0,0 @@
-// ARM API client — fetches real connector data with interactive Azure credentials.
-
-import { constants as fsConstants, promises as fs } from "node:fs";
-import { platform } from "node:os";
-import { basename, isAbsolute, join, resolve, sep } from "node:path";
-import { getToken } from "./auth.mjs";
-
-export { getToken };
-
-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";
-
-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;
-}
-
-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}.`);
-}
-
-/**
- * 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 function invalidateSubscriptionsCache() {
- s_subsCache = null;
-}
-
-export async function listSubscriptions({ forceRefresh = false } = {}) {
- const now = Date.now();
- if (!forceRefresh && 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
deleted file mode 100644
index 4050d8787..000000000
Binary files a/extensions/connector-namespaces/assets/preview-connected.png and /dev/null differ
diff --git a/extensions/connector-namespaces/assets/preview.png b/extensions/connector-namespaces/assets/preview.png
deleted file mode 100644
index 259e27941..000000000
Binary files a/extensions/connector-namespaces/assets/preview.png and /dev/null differ
diff --git a/extensions/connector-namespaces/auth.mjs b/extensions/connector-namespaces/auth.mjs
deleted file mode 100644
index c0eb5fd6a..000000000
--- a/extensions/connector-namespaces/auth.mjs
+++ /dev/null
@@ -1,236 +0,0 @@
-import { randomUUID } from "node:crypto";
-import { promises as fs } from "node:fs";
-import { homedir } from "node:os";
-import { join } from "node:path";
-import { InteractiveBrowserCredential } from "@azure/identity";
-
-export const ARM_SCOPE = "https://management.azure.com/.default";
-
-const TOKEN_EXPIRY_SKEW_MS = 5 * 60 * 1000;
-const SIGN_IN_SESSION_TTL_MS = 10 * 60 * 1000;
-const AUTH_STORAGE_DIR = join(
- process.env.COPILOT_HOME || join(homedir(), ".copilot"),
- "extensions",
- "connector-namespaces",
- "artifacts",
-);
-const AUTH_RECORD_FILE = join(AUTH_STORAGE_DIR, "azure-auth-record.json");
-const LEGACY_AUTH_CACHE = join(AUTH_STORAGE_DIR, "auth-cache.json");
-
-let legacyAuthArtifactsRemoved = false;
-
-async function removeLegacyAuthArtifacts() {
- if (legacyAuthArtifactsRemoved) return;
- for (const path of [AUTH_RECORD_FILE, LEGACY_AUTH_CACHE]) {
- try {
- await fs.unlink(path);
- } catch (error) {
- if (error?.code !== "ENOENT") {
- throw new Error(`Could not remove the legacy connector authentication file at ${path}: ${error.message}`);
- }
- }
- }
- legacyAuthArtifactsRemoved = true;
-}
-
-export class ConnectorAuthenticationRequiredError extends Error {
- constructor(message = "Sign in to Azure to continue.", options) {
- super(message, options);
- this.name = "ConnectorAuthenticationRequiredError";
- this.code = "authentication_required";
- }
-}
-
-export function isAuthenticationRequiredError(error) {
- return error instanceof ConnectorAuthenticationRequiredError
- || error?.code === "authentication_required"
- || error?.name === "AuthenticationRequiredError";
-}
-
-function credentialFactory(options) {
- return new InteractiveBrowserCredential(options);
-}
-
-function hasUsableToken(accessToken, now) {
- return !!accessToken?.token
- && Number.isFinite(accessToken.expiresOnTimestamp)
- && accessToken.expiresOnTimestamp - TOKEN_EXPIRY_SKEW_MS > now;
-}
-
-function errorDetail(error) {
- return String(error?.message || error || "Azure sign-in failed.").slice(0, 400);
-}
-
-export class InteractiveAuthBroker {
- constructor({
- createCredential = credentialFactory,
- createSessionId = randomUUID,
- cleanupLegacyCredentials = async () => {},
- now = Date.now,
- scope = ARM_SCOPE,
- } = {}) {
- this.createCredential = createCredential;
- this.createSessionId = createSessionId;
- this.cleanupLegacyCredentials = cleanupLegacyCredentials;
- this.now = now;
- this.scope = scope;
- this.credential = null;
- this.accessToken = null;
- this.cleanupInFlight = null;
- this.tokenInFlight = null;
- this.sessions = new Map();
- }
-
- createInteractiveCredential() {
- return this.createCredential({
- redirectUri: "http://localhost",
- disableAutomaticAuthentication: true,
- });
- }
-
- ensureLegacyCredentialsRemoved() {
- if (!this.cleanupInFlight) {
- const cleanup = Promise.resolve()
- .then(() => this.cleanupLegacyCredentials())
- .catch((error) => {
- if (this.cleanupInFlight === cleanup) this.cleanupInFlight = null;
- throw error;
- });
- this.cleanupInFlight = cleanup;
- }
- return this.cleanupInFlight;
- }
-
- pruneSessions() {
- const cutoff = this.now() - SIGN_IN_SESSION_TTL_MS;
- for (const [sessionId, session] of this.sessions) {
- if (session.createdAt >= cutoff) continue;
- session.status = "cancelled";
- session.abortController.abort();
- this.sessions.delete(sessionId);
- }
- }
-
- startSignIn() {
- this.pruneSessions();
- const sessionId = this.createSessionId();
- const abortController = new AbortController();
- let credential;
- try {
- credential = this.createInteractiveCredential();
- } catch (error) {
- return { ok: false, reason: "identity_unavailable", error: errorDetail(error) };
- }
-
- const session = {
- abortController,
- createdAt: this.now(),
- error: "",
- status: "pending",
- };
- this.sessions.set(sessionId, session);
-
- session.promise = Promise.resolve()
- .then(async () => {
- await this.ensureLegacyCredentialsRemoved();
- const authenticationRecord = await credential.authenticate(
- this.scope,
- { abortSignal: abortController.signal },
- );
- if (!authenticationRecord) {
- throw new Error("Azure identity did not return an authentication record.");
- }
- const accessToken = await credential.getToken(this.scope, { abortSignal: abortController.signal });
- if (!accessToken?.token || !Number.isFinite(accessToken.expiresOnTimestamp)) {
- throw new Error("Azure identity returned an incomplete ARM access token.");
- }
- if (session.status !== "pending" || this.sessions.get(sessionId) !== session) return;
- this.credential = credential;
- this.accessToken = accessToken;
- session.status = "done";
- })
- .catch((error) => {
- if (session.status !== "pending") return;
- session.status = abortController.signal.aborted ? "cancelled" : "error";
- if (session.status === "error") session.error = errorDetail(error);
- });
-
- return { ok: true, sessionId, mode: "interactive" };
- }
-
- getSignInStatus(sessionId) {
- this.pruneSessions();
- const session = this.sessions.get(sessionId);
- if (!session) return { ok: false, status: "unknown" };
- if (session.status === "pending") return { ok: true, status: "pending", mode: "interactive" };
-
- if (session.status === "done") return { ok: true, status: "done" };
- if (session.status === "cancelled") return { ok: true, status: "cancelled" };
- return { ok: false, status: "error", error: session.error || "Azure sign-in failed." };
- }
-
- cancelSignIn(sessionId) {
- const session = this.sessions.get(sessionId);
- if (!session || session.status !== "pending") return { ok: true };
- session.status = "cancelled";
- session.abortController.abort();
- return { ok: true };
- }
-
- async getToken() {
- await this.ensureLegacyCredentialsRemoved();
- if (hasUsableToken(this.accessToken, this.now())) return this.accessToken.token;
- if (this.tokenInFlight) return this.tokenInFlight;
-
- const request = (async () => {
- let credential = this.credential;
- if (!credential) {
- try {
- credential = this.createInteractiveCredential();
- this.credential = credential;
- } catch (error) {
- if (isAuthenticationRequiredError(error)) {
- throw new ConnectorAuthenticationRequiredError(
- "Sign in to Azure to continue.",
- { cause: error },
- );
- }
- throw error;
- }
- }
- try {
- const accessToken = await credential.getToken(this.scope);
- if (!accessToken?.token || !Number.isFinite(accessToken.expiresOnTimestamp)) {
- throw new Error("Azure identity returned an incomplete ARM access token.");
- }
- this.accessToken = accessToken;
- return accessToken.token;
- } catch (error) {
- if (!isAuthenticationRequiredError(error)) throw error;
- if (this.credential === credential) {
- this.credential = null;
- this.accessToken = null;
- }
- throw new ConnectorAuthenticationRequiredError(
- "Sign in to Azure to continue.",
- { cause: error },
- );
- }
- })();
- this.tokenInFlight = request;
- try {
- return await request;
- } finally {
- if (this.tokenInFlight === request) this.tokenInFlight = null;
- }
- }
-}
-
-export const interactiveAuth = new InteractiveAuthBroker({
- cleanupLegacyCredentials: removeLegacyAuthArtifacts,
-});
-
-export const startSignIn = () => interactiveAuth.startSignIn();
-export const getSignInStatus = (sessionId) => interactiveAuth.getSignInStatus(sessionId);
-export const cancelSignIn = (sessionId) => interactiveAuth.cancelSignIn(sessionId);
-export const getToken = () => interactiveAuth.getToken();
diff --git a/extensions/connector-namespaces/auth.test.mjs b/extensions/connector-namespaces/auth.test.mjs
deleted file mode 100644
index 332786465..000000000
--- a/extensions/connector-namespaces/auth.test.mjs
+++ /dev/null
@@ -1,302 +0,0 @@
-import { test } from "node:test";
-import assert from "node:assert/strict";
-
-import {
- ConnectorAuthenticationRequiredError,
- InteractiveAuthBroker,
-} from "./auth.mjs";
-
-function accessToken(token = "token", expiresOnTimestamp = 2_000_000_000_000) {
- return { token, expiresOnTimestamp };
-}
-
-function authenticationRecord(username = "user@example.com") {
- return {
- authority: "login.microsoftonline.com",
- homeAccountId: "home-account",
- clientId: "client-id",
- tenantId: "tenant-id",
- username,
- };
-}
-
-test("ARM token requests require an explicit browser sign-in", async () => {
- let credentialOptions;
- const broker = new InteractiveAuthBroker({
- createCredential(options) {
- credentialOptions = options;
- return {
- async getToken() {
- const error = new Error("No cached account found.");
- error.name = "AuthenticationRequiredError";
- throw error;
- },
- };
- },
- });
-
- await assert.rejects(
- broker.getToken(),
- (error) => error instanceof ConnectorAuthenticationRequiredError
- && error.code === "authentication_required",
- );
- assert.deepEqual(credentialOptions, {
- redirectUri: "http://localhost",
- disableAutomaticAuthentication: true,
- });
-});
-
-test("interactive sign-in reports pending then done and keeps the ARM token in memory", async () => {
- let credentialOptions;
- let authenticateOptions;
- const credential = {
- async authenticate(scope, options) {
- assert.equal(scope, "https://management.azure.com/.default");
- authenticateOptions = options;
- return authenticationRecord();
- },
- async getToken(scope, options) {
- assert.equal(scope, "https://management.azure.com/.default");
- assert.equal(options.abortSignal, authenticateOptions.abortSignal);
- return accessToken();
- },
- };
- const broker = new InteractiveAuthBroker({
- createCredential(options) {
- credentialOptions = options;
- return credential;
- },
- createSessionId: () => "signin-session",
- now: () => 1_000,
- });
-
- const started = broker.startSignIn();
- assert.deepEqual(started, {
- ok: true,
- sessionId: "signin-session",
- mode: "interactive",
- });
- assert.deepEqual(broker.getSignInStatus(started.sessionId), {
- ok: true,
- status: "pending",
- mode: "interactive",
- });
-
- await broker.sessions.get(started.sessionId).promise;
-
- assert.deepEqual(credentialOptions, {
- redirectUri: "http://localhost",
- disableAutomaticAuthentication: true,
- });
- assert.equal(authenticateOptions.abortSignal.aborted, false);
- assert.deepEqual(broker.getSignInStatus(started.sessionId), { ok: true, status: "done" });
- assert.deepEqual(broker.getSignInStatus(started.sessionId), { ok: true, status: "done" });
- assert.deepEqual(broker.cancelSignIn(started.sessionId), { ok: true });
- assert.equal(authenticateOptions.abortSignal.aborted, false);
- assert.deepEqual(broker.getSignInStatus(started.sessionId), { ok: true, status: "done" });
- assert.equal(await broker.getToken(), "token");
-});
-
-test("a new broker requires browser sign-in after the extension reloads", async () => {
- let authenticateCalls = 0;
- let createCredentialCalls = 0;
- const createCredential = (options) => {
- createCredentialCalls++;
- assert.deepEqual(options, {
- redirectUri: "http://localhost",
- disableAutomaticAuthentication: true,
- });
- let signedIn = false;
- return {
- async authenticate() {
- authenticateCalls++;
- signedIn = true;
- return authenticationRecord();
- },
- async getToken() {
- if (signedIn) return accessToken("memory-token");
- const error = new Error("No cached account found.");
- error.name = "AuthenticationRequiredError";
- throw error;
- },
- };
- };
- const signedInBroker = new InteractiveAuthBroker({
- createCredential,
- createSessionId: () => "persist-session",
- now: () => 1_000,
- });
- const started = signedInBroker.startSignIn();
- await signedInBroker.sessions.get(started.sessionId).promise;
- assert.equal(authenticateCalls, 1);
- assert.equal(await signedInBroker.getToken(), "memory-token");
-
- const restartedBroker = new InteractiveAuthBroker({
- createCredential,
- now: () => 1_000,
- });
- await assert.rejects(restartedBroker.getToken(), ConnectorAuthenticationRequiredError);
- assert.equal(authenticateCalls, 1);
- assert.equal(createCredentialCalls, 2);
-});
-
-test("concurrent first-time token requests share credential acquisition", async () => {
- let releaseToken;
- const tokenReady = new Promise((resolve) => {
- releaseToken = resolve;
- });
- let createCredentialCalls = 0;
- let tokenCalls = 0;
- const broker = new InteractiveAuthBroker({
- createCredential: () => {
- createCredentialCalls++;
- return {
- async getToken() {
- tokenCalls++;
- await tokenReady;
- return accessToken("shared-token");
- },
- };
- },
- });
-
- const firstRequest = broker.getToken();
- const secondRequest = broker.getToken();
- await new Promise((resolve) => setImmediate(resolve));
- releaseToken();
-
- assert.deepEqual(
- await Promise.all([firstRequest, secondRequest]),
- ["shared-token", "shared-token"],
- );
- assert.equal(createCredentialCalls, 1);
- assert.equal(tokenCalls, 1);
-});
-
-test("cancelling sign-in aborts the credential request", async () => {
- let abortSignal;
- const credential = {
- authenticate(_scope, options) {
- abortSignal = options.abortSignal;
- return new Promise((resolve, reject) => {
- options.abortSignal.addEventListener("abort", () => reject(new Error("aborted")), { once: true });
- });
- },
- async getToken() {
- const error = new Error("No cached account found.");
- error.name = "AuthenticationRequiredError";
- throw error;
- },
- };
- const broker = new InteractiveAuthBroker({
- createCredential: () => credential,
- createSessionId: () => "cancel-session",
- now: () => 1_000,
- });
-
- const started = broker.startSignIn();
- const pending = broker.sessions.get(started.sessionId).promise;
- await new Promise((resolve) => setImmediate(resolve));
- assert.equal(abortSignal.aborted, false);
-
- assert.deepEqual(broker.cancelSignIn(started.sessionId), { ok: true });
- await pending;
-
- assert.equal(abortSignal.aborted, true);
- assert.deepEqual(broker.getSignInStatus(started.sessionId), { ok: true, status: "cancelled" });
- assert.deepEqual(broker.getSignInStatus(started.sessionId), { ok: true, status: "cancelled" });
- await assert.rejects(broker.getToken(), ConnectorAuthenticationRequiredError);
-});
-
-test("sign-in failures are surfaced through the status endpoint contract", async () => {
- const broker = new InteractiveAuthBroker({
- createCredential: () => ({
- async authenticate() {
- throw new Error("browser launch failed");
- },
- async getToken() {
- throw new Error("unreachable");
- },
- }),
- createSessionId: () => "failed-session",
- now: () => 1_000,
- });
-
- const started = broker.startSignIn();
- await broker.sessions.get(started.sessionId).promise;
-
- assert.deepEqual(broker.getSignInStatus(started.sessionId), {
- ok: false,
- status: "error",
- error: "browser launch failed",
- });
- assert.deepEqual(broker.getSignInStatus(started.sessionId), {
- ok: false,
- status: "error",
- error: "browser launch failed",
- });
- assert.deepEqual(broker.cancelSignIn(started.sessionId), { ok: true });
- assert.deepEqual(broker.getSignInStatus(started.sessionId), {
- ok: false,
- status: "error",
- error: "browser launch failed",
- });
-});
-
-test("legacy credential cleanup retries after a transient failure", async () => {
- let cleanupCalls = 0;
- const broker = new InteractiveAuthBroker({
- cleanupLegacyCredentials: async () => {
- cleanupCalls++;
- if (cleanupCalls === 1) throw new Error("legacy cache is locked");
- },
- createCredential: () => ({
- async getToken() {
- return accessToken();
- },
- }),
- });
-
- await assert.rejects(broker.getToken(), /legacy cache is locked/);
- assert.equal(await broker.getToken(), "token");
- assert.equal(cleanupCalls, 2);
-});
-
-test("token acquisition preserves operational errors and retries the credential", async () => {
- const outage = new Error("Azure Identity network request timed out");
- let createCredentialCalls = 0;
- let tokenCalls = 0;
- const broker = new InteractiveAuthBroker({
- createCredential: () => {
- createCredentialCalls++;
- return {
- async getToken() {
- tokenCalls++;
- if (tokenCalls === 1) throw outage;
- return accessToken();
- },
- };
- },
- });
-
- await assert.rejects(broker.getToken(), (error) => error === outage);
- assert.equal(await broker.getToken(), "token");
- assert.equal(createCredentialCalls, 1);
- assert.equal(tokenCalls, 2);
-});
-
-test("incomplete tokens remain operational errors instead of prompting sign-in", async () => {
- const broker = new InteractiveAuthBroker({
- createCredential: () => ({
- async getToken() {
- return { token: "incomplete" };
- },
- }),
- });
-
- await assert.rejects(
- broker.getToken(),
- (error) => !(error instanceof ConnectorAuthenticationRequiredError)
- && error.message === "Azure identity returned an incomplete ARM access token.",
- );
-});
diff --git a/extensions/connector-namespaces/catalog.mjs b/extensions/connector-namespaces/catalog.mjs
deleted file mode 100644
index 79f4165cb..000000000
--- a/extensions/connector-namespaces/catalog.mjs
+++ /dev/null
@@ -1,70 +0,0 @@
-// 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
deleted file mode 100644
index 57cd9592f..000000000
--- a/extensions/connector-namespaces/categories.mjs
+++ /dev/null
@@ -1,15 +0,0 @@
-// 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
deleted file mode 100644
index 3949dbe48..000000000
--- a/extensions/connector-namespaces/copilot-extension.json
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "name": "connector-namespaces",
- "version": 1
-}
diff --git a/extensions/connector-namespaces/createPage.mjs b/extensions/connector-namespaces/createPage.mjs
deleted file mode 100644
index 3eb866592..000000000
--- a/extensions/connector-namespaces/createPage.mjs
+++ /dev/null
@@ -1,403 +0,0 @@
-// 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
deleted file mode 100644
index 94025ee71..000000000
--- a/extensions/connector-namespaces/extension.mjs
+++ /dev/null
@@ -1,101 +0,0 @@
-// 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: "Discover, connect, and manage hosted MCP servers from Azure Connector Namespace.",
- 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
deleted file mode 100644
index 74264d6fb..000000000
--- a/extensions/connector-namespaces/install.mjs
+++ /dev/null
@@ -1,1007 +0,0 @@
-// 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
deleted file mode 100644
index b1f2cfa00..000000000
--- a/extensions/connector-namespaces/install.reauth.test.mjs
+++ /dev/null
@@ -1,558 +0,0 @@
-// 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 { existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
-import { 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. Seed the interactive broker with an in-memory token so ARM
-// calls stay 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 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" } } }),
-);
-
-const { interactiveAuth } = await import("./auth.mjs");
-interactiveAuth.credential = {
- async getToken() {
- return { token: "fake-token", expiresOnTimestamp: Date.now() + 60 * 60 * 1000 };
- },
-};
-interactiveAuth.accessToken = { token: "fake-token", expiresOnTimestamp: Date.now() + 60 * 60 * 1000 };
-
-// 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
deleted file mode 100644
index 5e51de54a..000000000
--- a/extensions/connector-namespaces/install.test.mjs
+++ /dev/null
@@ -1,253 +0,0 @@
-// 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
deleted file mode 100644
index 009f10864..000000000
--- a/extensions/connector-namespaces/mcp-http-probe.test.mjs
+++ /dev/null
@@ -1,58 +0,0 @@
-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
deleted file mode 100644
index 26f54bbcb..000000000
--- a/extensions/connector-namespaces/package.json
+++ /dev/null
@@ -1,20 +0,0 @@
-{
- "name": "connector-namespaces",
- "version": "1.2.0",
- "type": "module",
- "main": "extension.mjs",
- "description": "Interactive GitHub Copilot canvas for discovering, connecting, and managing hosted MCP servers from Azure Connector Namespace.",
- "keywords": [
- "azure",
- "connector-namespace",
- "mcp",
- "mcp-connectors",
- "model-context-protocol",
- "tool-discovery"
- ],
- "license": "MIT",
- "dependencies": {
- "@azure/identity": "4.13.1",
- "@github/copilot-sdk": "1.0.6"
- }
-}
diff --git a/extensions/connector-namespaces/preview/.gitignore b/extensions/connector-namespaces/preview/.gitignore
deleted file mode 100644
index 0bac4c45f..000000000
--- a/extensions/connector-namespaces/preview/.gitignore
+++ /dev/null
@@ -1,2 +0,0 @@
-# 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
deleted file mode 100644
index 4413d5fb3..000000000
--- a/extensions/connector-namespaces/preview/README.md
+++ /dev/null
@@ -1,112 +0,0 @@
-# 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 the GitHub Copilot app…" 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
deleted file mode 100644
index 0fce100b8..000000000
--- a/extensions/connector-namespaces/preview/fixtures.mjs
+++ /dev/null
@@ -1,117 +0,0 @@
-// 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
deleted file mode 100644
index d56a14c60..000000000
--- a/extensions/connector-namespaces/preview/server.mjs
+++ /dev/null
@@ -1,151 +0,0 @@
-// 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
deleted file mode 100644
index aab8f17b1..000000000
--- a/extensions/connector-namespaces/preview/shots.mjs
+++ /dev/null
@@ -1,96 +0,0 @@
-// 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
deleted file mode 100644
index b2a74c6a1..000000000
--- a/extensions/connector-namespaces/renderer.mjs
+++ /dev/null
@@ -1,1762 +0,0 @@
-// 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 = "", { linkedNamespace = "" } = {}) {
- const hasLinkedNamespace = typeof linkedNamespace === "string" && linkedNamespace.length > 0;
- const pageTitle = hasLinkedNamespace ? "Sign in to MCP Connectors" : "Select Connector Namespace";
- const heading = hasLinkedNamespace ? "Sign in to see your connectors" : "Select a Connector Namespace";
- const subheading = hasLinkedNamespace
- ? `Connector namespace ${esc(linkedNamespace)} is already linked.`
- : "Choose which connector namespace to browse. This choice is saved for future sessions.";
- const defaultSigninMessage = hasLinkedNamespace
- ? "Sign in to Azure to view and manage its connectors."
- : "Sign in to Azure to load your subscriptions and connector namespaces.";
- const subOptions = subscriptions.map((s) =>
- `${esc(s.name)} (${s.id.slice(0, 8)}\u2026) `
- ).join("");
-
- return `
-
-${pageTitle} ${baseStyles()}
-
-
-
${brandMark(30, "setup")}${heading}
-
${subheading}
-
-${notice ? `${esc(notice)}
` : ""}
-
-
${defaultSigninMessage}
-
- Sign in to Azure
- Cancel
-
-
-
-
- + New connector namespace
-
-
- Subscription
-
- -- Select subscription --
- ${subOptions}
-
-
-
-
-
${subscriptions.length ? "Select a subscription to see available connector namespaces." : "Loading subscriptions\u2026"}
-
-
-`;
-}
-
-// ---------------------------------------------------------------------------
-// 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 the GitHub Copilot app to use newly added tools. Connectors are saved to your MCP config now, but their tools only load when the app 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
deleted file mode 100644
index fbb57e524..000000000
--- a/extensions/connector-namespaces/renderer.test.mjs
+++ /dev/null
@@ -1,447 +0,0 @@
-// 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 the GitHub Copilot app" 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("setup prompts, polls, cancels, and reloads subscriptions after browser sign-in", () => {
- const html = renderSetupHtml([], "", "token");
- assert.match(html, /id="signin-btn"/);
- assert.match(html, /fetch\("\/api\/signin"/);
- assert.match(html, /\/api\/signin\/status\?sessionId=/);
- assert.match(html, /fetch\("\/api\/signin\/cancel"/);
- assert.match(html, /setTimeout\(pollSignin, 2500\)/);
- assert.match(html, /await loadSubscriptions\(true\)/);
- assert.match(html, /\/api\/subscriptions/);
-});
-
-test("a linked namespace gets a focused sign-in state and returns to its catalog", () => {
- const html = renderSetupHtml([], "", "token", { linkedNamespace: "saved-gateway" });
- assert.match(html, /Sign in to see your connectors/);
- assert.match(html, /Connector namespace saved-gateway<\/code> is already linked\./);
- assert.match(html, /Sign in to Azure to view and manage its connectors\./);
- assert.match(html, /const hasLinkedNamespace = true/);
- assert.match(html, /window\.location\.replace\("\/"\)/);
- assert.match(html, //);
-});
-
-test("linked namespace sign-in escapes saved names and produces valid client JavaScript", () => {
- const html = renderSetupHtml([], "", "token", {
- linkedNamespace: `gateway`,
- });
- assert.doesNotMatch(html, /