From a6dcdf7a0587784e78a2a02296d33e9e326e4ba6 Mon Sep 17 00:00:00 2001 From: Alex Yang Date: Fri, 26 Jun 2026 14:38:22 -0700 Subject: [PATCH 1/4] Add MCP Connectors (connector-namespaces) canvas extension A Copilot CLI canvas extension for browsing and adding MCP connectors from an Azure Connector Namespace into a Copilot session. Sign-in is dependency-free (OAuth 2.0 auth-code + PKCE via the Azure CLI public client, loopback redirect); network access is restricted to the public Azure Resource Manager endpoint. MIT licensed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- extensions/connector-namespaces/LICENSE | 21 + extensions/connector-namespaces/README.md | 41 + extensions/connector-namespaces/armClient.mjs | 543 ++++++++++ .../connector-namespaces/assets/preview.png | Bin 0 -> 73080 bytes extensions/connector-namespaces/canvas.json | 28 + extensions/connector-namespaces/catalog.mjs | 69 ++ .../connector-namespaces/createPage.mjs | 359 +++++++ extensions/connector-namespaces/extension.mjs | 73 ++ extensions/connector-namespaces/install.mjs | 593 +++++++++++ .../connector-namespaces/mcp-unwrap-proxy.mjs | 189 ++++ extensions/connector-namespaces/package.json | 19 + extensions/connector-namespaces/renderer.mjs | 936 ++++++++++++++++++ extensions/connector-namespaces/server.mjs | 384 +++++++ extensions/connector-namespaces/state.mjs | 102 ++ 14 files changed, 3357 insertions(+) create mode 100644 extensions/connector-namespaces/LICENSE create mode 100644 extensions/connector-namespaces/README.md create mode 100644 extensions/connector-namespaces/armClient.mjs create mode 100644 extensions/connector-namespaces/assets/preview.png create mode 100644 extensions/connector-namespaces/canvas.json create mode 100644 extensions/connector-namespaces/catalog.mjs create mode 100644 extensions/connector-namespaces/createPage.mjs create mode 100644 extensions/connector-namespaces/extension.mjs create mode 100644 extensions/connector-namespaces/install.mjs create mode 100644 extensions/connector-namespaces/mcp-unwrap-proxy.mjs create mode 100644 extensions/connector-namespaces/package.json create mode 100644 extensions/connector-namespaces/renderer.mjs create mode 100644 extensions/connector-namespaces/server.mjs create mode 100644 extensions/connector-namespaces/state.mjs diff --git a/extensions/connector-namespaces/LICENSE b/extensions/connector-namespaces/LICENSE new file mode 100644 index 000000000..22aed37e6 --- /dev/null +++ b/extensions/connector-namespaces/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Microsoft Corporation. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/extensions/connector-namespaces/README.md b/extensions/connector-namespaces/README.md new file mode 100644 index 000000000..18728ffc2 --- /dev/null +++ b/extensions/connector-namespaces/README.md @@ -0,0 +1,41 @@ +# MCP Connectors + +A Copilot CLI canvas extension for browsing and adding [Model Context Protocol (MCP)](https://modelcontextprotocol.io) connectors from an Azure Connector Namespace directly inside your Copilot session. + +![MCP Connectors canvas](assets/preview.png) + +## What it does + +Opens an interactive canvas in Copilot where you can: + +- Browse the MCP connectors published to your Azure Connector Namespace. +- Search and filter connectors by name or category. +- Add a connector to your session with one click to enable its tools. + +Once added, the connector's MCP tools become available to Copilot for the rest of your session. + +## Prerequisites + +- An Azure account with access to a **Connector Namespace** (the `Microsoft.Web/connectorGateways` resource). If you don't have one yet, the canvas links you to creating one. +- A modern web browser for sign-in. + +You do **not** need the Azure CLI installed. The extension signs you in through your browser the first time it needs to talk to Azure. + +## Usage + +1. Install the extension and open the **MCP Connectors** canvas in Copilot. +2. The first time the canvas needs Azure, a browser tab opens for sign-in. Complete sign-in and return to Copilot. +3. Pick the subscription that holds your Connector Namespace. +4. Browse or search the available connectors and add the ones you want. + +If a subscription has no Connector Namespace, the canvas shows a short setup guide instead of an empty list. + +## How sign-in works + +Authentication uses the standard OAuth 2.0 authorization-code flow with PKCE against the well-known Azure CLI public client. A short-lived loopback HTTP listener captures the redirect, exchanges the code for an Azure Resource Manager token, and caches it for reuse. No client secret is used. The refresh token is cached under your Copilot home directory (`~/.copilot/extensions/connector-namespaces/artifacts/auth-cache.json`, written with owner-only `0600` permissions) so you are not prompted to sign in every session. Tokens are refreshed silently until the refresh token itself expires, after which you sign in once more. + +All Azure Resource Manager calls are restricted to the public ARM endpoint (`management.azure.com`); the extension does not contact any other host. + +## License + +[MIT](LICENSE) diff --git a/extensions/connector-namespaces/armClient.mjs b/extensions/connector-namespaces/armClient.mjs new file mode 100644 index 000000000..fa422e718 --- /dev/null +++ b/extensions/connector-namespaces/armClient.mjs @@ -0,0 +1,543 @@ +// ARM API client — fetches real connector data via an interactive Azure sign-in. + +import { createServer } from "node:http"; +import { createHash, randomBytes } from "node:crypto"; +import { spawn } from "node:child_process"; +import { platform, homedir } from "node:os"; +import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"; +import { join } from "node:path"; + +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"; + +// Azure CLI's well-known first-party public client. It already has an +// http://localhost loopback redirect registered, so we can run an interactive +// auth-code + PKCE sign-in against it with no app registration of our own. AAD +// accepts any port on a loopback redirect for public clients, so there's no +// secret to ship and nothing for a teammate to configure. +const CLIENT_ID = "04b07795-8ddb-461a-bbee-02f9e1bf7b46"; +const AUTHORITY = "https://login.microsoftonline.com/organizations"; +// offline_access gets us a refresh token so we prompt the browser once per +// session, then renew silently as the ARM token expires. +const SCOPE = "https://management.azure.com/.default offline_access"; +const SIGN_IN_TIMEOUT_MS = 5 * 60 * 1000; + +const base64url = (buf) => + buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + +// Open a URL in the default browser without shelling out to az. rundll32's +// FileProtocolHandler on Windows avoids cmd's `&` parsing on query strings. +function openBrowser(url) { + const os = platform(); + const [cmd, args] = + os === "win32" + ? ["rundll32", ["url.dll,FileProtocolHandler", url]] + : os === "darwin" + ? ["open", [url]] + : ["xdg-open", [url]]; + try { + spawn(cmd, args, { stdio: "ignore", detached: true }).unref(); + } catch { + // If launching a browser fails, the URL is printed below so the user + // can open it by hand (e.g. over SSH). + } +} + +async function tokenRequest(form) { + const res = await fetch(`${AUTHORITY}/oauth2/v2.0/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: form.toString(), + }); + const data = await res.json(); + if (!res.ok) { + throw new Error(`Azure sign-in failed: ${data.error_description || data.error || res.status}`); + } + return { + token: data.access_token, + refreshToken: data.refresh_token, + // expires_in is seconds-from-now; store an absolute ms timestamp. + expiresAt: Date.now() + (Number(data.expires_in) || 3600) * 1000, + }; +} + +function exchangeCode(code, verifier, redirectUri) { + return tokenRequest( + new URLSearchParams({ + client_id: CLIENT_ID, + grant_type: "authorization_code", + code, + redirect_uri: redirectUri, + code_verifier: verifier, + scope: SCOPE, + }), + ); +} + +function refreshAccessToken(refreshToken) { + return tokenRequest( + new URLSearchParams({ + client_id: CLIENT_ID, + grant_type: "refresh_token", + refresh_token: refreshToken, + scope: SCOPE, + }), + ); +} + +// One interactive sign-in: start a loopback server on a random free port, open +// the browser to the AAD authorize endpoint, capture the redirected code, and +// exchange it for an ARM token. +function interactiveSignIn() { + return new Promise((resolve, reject) => { + const verifier = base64url(randomBytes(32)); + const challenge = base64url(createHash("sha256").update(verifier).digest()); + const state = base64url(randomBytes(16)); + let redirectUri = ""; + let settled = false; + let authStarted = false; + + // One handler shared by two loopback listeners (IPv4 + IPv6) on the same + // OS-chosen port. The redirect_uri uses "localhost" because that's the + // loopback host registered for the Azure CLI public client, and on + // Windows "localhost" can resolve to either 127.0.0.1 or ::1, so we + // listen on both stacks to guarantee the AAD redirect reaches us. + const handler = (req, res) => { + const reqUrl = new URL(req.url, "http://localhost"); + if (reqUrl.pathname !== "/") { + res.writeHead(404).end(); + return; + } + const code = reqUrl.searchParams.get("code"); + const returnedState = reqUrl.searchParams.get("state"); + res.writeHead(200, { "Content-Type": "text/html" }); + res.end( + "" + + "

Signed in to Azure

You can close this tab and return to your terminal.

" + + "", + ); + if (!code || returnedState !== state) { + finish(reject, new Error("Azure sign-in was cancelled or returned an unexpected response.")); + return; + } + exchangeCode(code, verifier, redirectUri).then( + (auth) => finish(resolve, auth), + (err) => finish(reject, err), + ); + }; + + const v4 = createServer(handler); + const v6 = createServer(handler); + + const finish = (fn, arg) => { + if (settled) return; + settled = true; + clearTimeout(timer); + for (const s of [v4, v6]) { + try { + s.close(); + } catch { + // listener may never have bound (e.g. no IPv6 loopback); ignore. + } + } + fn(arg); + }; + + const timer = setTimeout( + () => finish(reject, new Error("Azure sign-in timed out. Run the action again to retry.")), + SIGN_IN_TIMEOUT_MS, + ); + + const startAuth = () => { + if (authStarted) return; + authStarted = true; + const authUrl = + `${AUTHORITY}/oauth2/v2.0/authorize?` + + new URLSearchParams({ + client_id: CLIENT_ID, + response_type: "code", + redirect_uri: redirectUri, + response_mode: "query", + scope: SCOPE, + state, + code_challenge: challenge, + code_challenge_method: "S256", + prompt: "select_account", + }).toString(); + console.log(`\nSign in to Azure to load your connector namespaces:\n${authUrl}\n`); + openBrowser(authUrl); + }; + + // IPv4 is the primary listener and its errors are fatal. IPv6 is + // best-effort: machines without IPv6 loopback just use IPv4, so its + // bind errors are swallowed and we start sign-in once either settles. + v4.on("error", (err) => finish(reject, err)); + v6.on("error", () => startAuth()); + + // Port 0 lets the OS pick a free port; loopback redirects accept any port. + v4.listen(0, "127.0.0.1", () => { + const { port } = v4.address(); + redirectUri = `http://localhost:${port}`; + v6.listen(port, "::1", () => startAuth()); + }); + }); +} + +// One browser sign-in, then silent refresh-token renewals as the ARM access +// token expires (~60-90min). Refresh 5min early. A single-flight guard makes +// concurrent callers share one sign-in instead of opening two tabs. +// +// The cache is also persisted to disk so a process restart (CLI reload, host +// recycle, crash) renews silently from the saved refresh token instead of +// popping the browser again. Without this, every restart wiped the in-memory +// token and forced a fresh interactive sign-in. +const TOKEN_DIR = join(process.env.COPILOT_HOME || join(homedir(), ".copilot"), "extensions", "connector-namespaces", "artifacts"); +const TOKEN_FILE = join(TOKEN_DIR, "auth-cache.json"); + +let s_auth = null; // { token, refreshToken, expiresAt } +let s_authInFlight = null; +let s_diskLoaded = false; +const EXPIRY_SKEW_MS = 5 * 60 * 1000; + +function loadAuthCache() { + try { + if (existsSync(TOKEN_FILE)) { + const data = JSON.parse(readFileSync(TOKEN_FILE, "utf-8")); + if (data && typeof data.refreshToken === "string" && data.refreshToken.length > 0) { + return data; + } + } + } catch { + // Corrupt or unreadable cache — ignore and sign in fresh. + } + return null; +} + +function saveAuthCache(auth) { + try { + if (!existsSync(TOKEN_DIR)) { + mkdirSync(TOKEN_DIR, { recursive: true }); + } + const payload = JSON.stringify( + { token: auth.token, refreshToken: auth.refreshToken, expiresAt: auth.expiresAt }, + null, + 2, + ); + writeFileSync(TOKEN_FILE, payload, { encoding: "utf-8", mode: 0o600 }); + } catch { + // Best-effort; the in-memory cache still serves this process. + } +} + +// Pull the persisted token in once per process, before the first acquire. +function hydrateFromDisk() { + if (s_diskLoaded) { + return; + } + s_diskLoaded = true; + if (!s_auth) { + const cached = loadAuthCache(); + if (cached) { + s_auth = cached; + } + } +} + +async function acquireToken() { + if (s_auth?.refreshToken) { + try { + s_auth = await refreshAccessToken(s_auth.refreshToken); + saveAuthCache(s_auth); + return s_auth.token; + } catch { + // Refresh token expired or revoked — fall through to interactive. + } + } + s_auth = await interactiveSignIn(); + saveAuthCache(s_auth); + return s_auth.token; +} + +export async function getToken() { + hydrateFromDisk(); + if (s_auth && s_auth.expiresAt - EXPIRY_SKEW_MS > Date.now()) return s_auth.token; + if (s_authInFlight) return s_authInFlight; + s_authInFlight = acquireToken().finally(() => { + s_authInFlight = null; + }); + return s_authInFlight; +} + +/** + * List all enabled Azure subscriptions the user has access to. + */ +// The set of enabled subscriptions is stable for a session, so cache it — the +// first /setup pays the ARM round-trip once and every "Change namespace" +// afterwards serves from memory. +let s_subsCache = null; // { subs, expiresAt } +const SUBS_TTL_MS = 30 * 60 * 1000; + +export async function listSubscriptions() { + const now = Date.now(); + if (s_subsCache && s_subsCache.expiresAt > now) return s_subsCache.subs; + const token = await getToken(); + const url = `https://management.azure.com/subscriptions?api-version=${SUBS_API_VERSION}`; + const raw = await paginateAll(url, token); + const subs = raw + .filter((s) => s.state === "Enabled") + .map((s) => ({ id: s.subscriptionId, name: s.displayName, tenantId: s.tenantId, state: s.state })); + s_subsCache = { subs, expiresAt: now + SUBS_TTL_MS }; + return subs; +} + +// ARM resource identifiers are a restricted charset (letters, digits and a few +// punctuation chars). Validating each path segment against this allowlist before +// it enters a URL rejects anything containing "/", "?", "#", "@" or ":" — the +// characters that could otherwise alter the request path or redirect the host — +// and acts as a taint barrier so config/file-derived names cannot reach fetch +// unvalidated. +const ARM_SEGMENT = /^[A-Za-z0-9._()-]{1,256}$/; + +export function armSegment(value) { + const s = String(value); + if (!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) { + if (!url.startsWith(ARM_BASE)) { + throw new Error(`Refusing to call non-ARM URL: ${url}`); + } + const token = await getToken(); + const headers = { Authorization: `Bearer ${token}` }; + 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 (or update) a resource group. Idempotent PUT. + */ +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 }); +} + +/** + * 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; +} + +const TERMINAL_STATES = new Set(["Succeeded", "Failed", "Canceled"]); + +/** + * Create a connector namespace (idempotent PUT) 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 }; + let result = await armWrite("PUT", url, body); + let state = result?.properties?.provisioningState; + // ~3 min ceiling (60 * 3s). connectorGateways usually settle in seconds. + for (let i = 0; i < 60 && state && !TERMINAL_STATES.has(state); i++) { + await sleep(3000); + result = await armFetch(url, token); + state = result?.properties?.provisioningState; + } + if (state === "Failed" || state === "Canceled") { + throw new Error(`Provisioning ${state} for "${gatewayName}".`); + } + return result; +} diff --git a/extensions/connector-namespaces/assets/preview.png b/extensions/connector-namespaces/assets/preview.png new file mode 100644 index 0000000000000000000000000000000000000000..ff80d29159b018476cd1e4cd0c3b3cd1382fcea7 GIT binary patch literal 73080 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

Z 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 T-^zJ^e$#7N { + 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/createPage.mjs b/extensions/connector-namespaces/createPage.mjs new file mode 100644 index 000000000..86e9b8f61 --- /dev/null +++ b/extensions/connector-namespaces/createPage.mjs @@ -0,0 +1,359 @@ +// 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 = "") { + const subOptions = subscriptions.map((s) => + `` + ).join(""); + + const regionOptions = REGIONS.map(([v, l]) => + `` + ).join(""); + + return ` + +Create Connector Namespace${baseStyles()} + + +
+

${brandMark(28, "create")}Create connector namespace

+
Provisions a real Azure connector namespace (Microsoft.Web/connectorGateways) in your subscription.
+
+ +
+ + +
+ +
+ +
+ + +
+ + +
Pick the resource group the namespace will live in.
+
+ +
+ + +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ + +
+ +
+ +
+ + +
+ +
+ +`; +} diff --git a/extensions/connector-namespaces/extension.mjs b/extensions/connector-namespaces/extension.mjs new file mode 100644 index 000000000..c23bfe364 --- /dev/null +++ b/extensions/connector-namespaces/extension.mjs @@ -0,0 +1,73 @@ +// Canvas extension entry point — MCP Connectors browser. + +import { joinSession, createCanvas } from "@github/copilot-sdk/extension"; +import { startServer, stopServer } from "./server.mjs"; +import { loadSavedConfig, saveConfig, getSessionConfig, getAddedConnectors, addConnector, removeConnector } from "./state.mjs"; +import { fetchCatalog } from "./catalog.mjs"; +import { setWorkspaceRoot } from "./install.mjs"; + +// Load any previously saved connector namespace config on startup +loadSavedConfig(); + +const session = await joinSession({ + canvases: [ + createCanvas({ + id: "connector-namespaces", + displayName: "MCP Connectors", + description: "Browse and add MCP connectors to your session \u2014 search by name or category, then add connectors to enable their tools.", + 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: "add_connector", + description: "Add an MCP connector to the current session by ID", + inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] }, + handler: async (ctx) => { + const config = getSessionConfig(); + if (!config) return { added: false, reason: "no_gateway_configured" }; + const catalog = await fetchCatalog(config.subscriptionId, config.resourceGroup, config.gatewayName); + const connector = catalog.find((c) => c.id === ctx.input.id); + if (!connector) return { added: false, reason: "not_found" }; + return addConnector(connector); + }, + }, + { + name: "remove_connector", + description: "Remove a previously added connector by ID", + inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] }, + handler: async (ctx) => removeConnector(ctx.input.id), + }, + { + name: "list_connectors", + description: "List all connectors currently added to the session", + handler: async () => ({ connectors: getAddedConnectors() }), + }, + ], + open: async (ctx) => { + // If explicit input provided, use it and save for future + if (ctx.input && ctx.input.subscriptionId && ctx.input.resourceGroup && ctx.input.gatewayName) { + saveConfig({ + subscriptionId: ctx.input.subscriptionId, + resourceGroup: ctx.input.resourceGroup, + gatewayName: ctx.input.gatewayName, + }); + } + // Otherwise rely on saved config (loaded on startup) — server handles the picker if none saved. + const entry = await startServer(ctx.instanceId); + return { title: "MCP Connectors", url: entry.url }; + }, + onClose: async (ctx) => { + await stopServer(ctx.instanceId); + }, + }), + ], +}); + +// Tell the install pipeline where the workspace .mcp.json lives (if any). +setWorkspaceRoot(session.workspacePath); diff --git a/extensions/connector-namespaces/install.mjs b/extensions/connector-namespaces/install.mjs new file mode 100644 index 000000000..f13648c5c --- /dev/null +++ b/extensions/connector-namespaces/install.mjs @@ -0,0 +1,593 @@ +// 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 { fileURLToPath } from "node:url"; +import { getToken, assertArmHost, armSegment } from "./armClient.mjs"; + +// Stdio shim the Copilot CLI spawns instead of connecting to the gateway's MCP +// endpoint directly. The gateway wraps post-initialize responses (tools/list, +// tools/call) in an Azure Logic Apps "$content" base64 envelope that the CLI's +// HTTP MCP client cannot parse, so no tools load. The proxy unwraps it and +// speaks clean JSON-RPC over stdio. See mcp-unwrap-proxy.mjs. +const MCP_PROXY_PATH = join(dirname(fileURLToPath(import.meta.url)), "mcp-unwrap-proxy.mjs"); + +// 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(homedir(), ".copilot", "mcp-config.json"); +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; +} + +// Serialize every read-modify-write of the CLI MCP config so concurrent +// connect/disconnect operations can't clobber each other's entries. +let s_configLock = Promise.resolve(); +function withConfigLock(fn) { + const run = s_configLock.then(fn, fn); + s_configLock = run.then(() => {}, () => {}); + return run; +} + +// 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. Mirrors the guard in mcp-unwrap-proxy.mjs. +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) +// --------------------------------------------------------------------------- + +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); + 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) { + const msg = parsed?.error?.message ?? parsed?.message ?? text ?? `HTTP ${res.status}`; + const err = new Error(`ARM ${method} ${res.status}: ${msg}`); + err.status = res.status; + throw err; + } + return parsed; +} + +// 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 -> Promise<{ connectionParameters, connectionParameterSets, opId }> + +function loadConnectorMeta(config, apiName, location) { + if (metaCache.has(apiName)) return metaCache.get(apiName); + const promise = (async () => { + const sub = armSegment(config.subscriptionId); + const base = `/subscriptions/${sub}/providers/Microsoft.Web/locations/${armSegment(location)}/managedApis/${armSegment(apiName)}`; + const [meta, swagger] = await Promise.all([ + arm("GET", `${base}?api-version=${MANAGED_API_VERSION}`).catch(() => null), + arm("GET", `${base}?api-version=${MANAGED_API_VERSION}&export=true`).catch(() => null), + ]); + 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(apiName)); + metaCache.set(apiName, 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 pending = apiNames.filter((name) => !metaCache.has(name)); + 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/${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/${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) { + 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 configName = generateName(displayName); + 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/${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"); + const parsed = JSON.parse(raw); + if (!parsed.mcpServers || typeof parsed.mcpServers !== "object") parsed.mcpServers = {}; + return parsed; + } catch (e) { + if (e.code === "ENOENT") return { mcpServers: {} }; + throw e; + } +} + +export async function writeMcpEntry(name, url, key, scope = "profile") { + assertSafeMcpTarget(url); + const path = mcpConfigPath(scope); + return withConfigLock(async () => { + const cfg = await readMcpConfigAt(path); + // Route through the stdio unwrap-proxy rather than a direct { type: "http" } + // entry — the gateway's $content envelope breaks the CLI's HTTP MCP client. + cfg.mcpServers[name] = { + command: "node", + args: [MCP_PROXY_PATH], + env: { MCP_TARGET_URL: url, MCP_API_KEY: key }, + }; + // The file holds a long-lived API key — keep it and its directory + // owner-only. chmod re-asserts 0600 when the file already existed + // (writeFile mode only applies on create); it's a benign no-op on Windows. + await fs.mkdir(dirname(path), { recursive: true, mode: 0o700 }); + await fs.writeFile(path, JSON.stringify(cfg, null, 2) + "\n", { encoding: "utf8", mode: 0o600 }); + await fs.chmod(path, 0o600).catch(() => {}); + }); +} + +// Remove the entry from whichever scope(s) it lives in. +export async function removeMcpEntry(name) { + return withConfigLock(async () => { + let removed = false; + for (const scope of ["profile", "workspace"]) { + let path; + try { path = mcpConfigPath(scope); } catch { continue; } + let cfg; + try { cfg = await readMcpConfigAt(path); } catch { continue; } + if (Object.prototype.hasOwnProperty.call(cfg.mcpServers, name)) { + delete cfg.mcpServers[name]; + await fs.writeFile(path, JSON.stringify(cfg, null, 2) + "\n", { encoding: "utf8", mode: 0o600 }); + await fs.chmod(path, 0o600).catch(() => {}); + removed = true; + } + } + return removed; + }); +} + +// 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]; + if (!entry) return { ok: true, removed: false }; + + // Drop the local CLI entry first — fast and local. + if (entry.configName) await removeMcpEntry(entry.configName); + + // Delete the gateway mcpserverConfig. This is the resource that drives the + // "installed" flag, so a swallowed failure here is exactly what makes a + // removed tile look like it's still there — surface it (404 = already gone). + if (entry.configName) { + await armDelete(`${gatewayId(config)}/mcpserverConfigs/${armSegment(entry.configName)}?api-version=${API_VERSION}`); + } + // The connection is secondary; best-effort delete. + if (entry.connectionName) { + await armDelete(`${gatewayId(config)}/connections/${armSegment(entry.connectionName)}?api-version=${API_VERSION}`).catch(() => {}); + } + + // The config DELETE is a long-running op — the resource keeps listing until + // it converges. Poll the gateway until it's gone so the client's next state + // refresh actually reports the connector as uninstalled (up to ~15s). + if (entry.configName) { + for (let i = 0; i < 20; i++) { + const list = await arm("GET", `${gatewayId(config)}/mcpserverConfigs?api-version=${API_VERSION}`).catch(() => ({ value: [] })); + if (!(list.value ?? []).some((c) => c.name === entry.configName)) break; + await sleep(750); + } + } + + 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}`).catch(() => {}); + return { ok: true, removed: true }; +} + +// --------------------------------------------------------------------------- +// Full install pipeline +// --------------------------------------------------------------------------- + +export async function installConnector(config, apiName, displayName, callbackBase, scope = "profile") { + const location = await getGatewayLocation(config); + const meta = await loadConnectorMeta(config, apiName, location); + + // 1. Create connection + const connName = await createConnection(config, apiName, displayName, location); + + // The OAuth redirect must carry the connName so the loopback callback keys + // pendingOAuth by the same value the client polls on. + const callbackUrl = `${callbackBase}${encodeURIComponent(connName)}`; + + // 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") { + return 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 }; + } + + // 4. No consent link and not Connected — try to finish anyway. + return finishInstall(config, apiName, displayName, connName, location, scope); +} + +export async function finishInstall(config, apiName, displayName, connName, location, scope = "profile") { + if (!location) location = await getGatewayLocation(config); + const meta = await loadConnectorMeta(config, apiName, location); + + // Poll connection status up to ~20s for Connected. + let status = "Unknown"; + for (let i = 0; i < 20; i++) { + status = await getConnectionStatus(config, connName); + if (status === "Connected") break; + await sleep(1000); + } + + // Create MCP server config (endpoint URL comes back on the PUT response). + let { configName, endpointUrl } = await createMcpServerConfig(config, apiName, displayName, connName, location, meta.opId); + + // 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. + const key = await mintApiKey(config, configName); + await writeMcpEntry(configName, endpointUrl, key, scope); + + const warning = status === "Connected" ? undefined : `Connection ended in state "${status}". You may need to re-authenticate.`; + return { ok: true, configName, connName, endpointUrl, scope, warning }; +} + +// --------------------------------------------------------------------------- +// 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}`).catch(() => ({ value: [] })), + arm("GET", `${gatewayId(config)}/connections?api-version=${API_VERSION}`).catch(() => ({ value: [] })), + readMcpConfigAt(PROFILE_MCP_PATH).catch(() => ({ mcpServers: {} })), + wsPath ? readMcpConfigAt(wsPath).catch(() => ({ mcpServers: {} })) : 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 ?? {})); + + // Map apiName -> install state. + const byApi = {}; + for (const cfg of configsRes.value ?? []) { + 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); + byApi[apiName] = { + 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), + }; + } + return byApi; +} + +// --------------------------------------------------------------------------- +// Browser opener +// --------------------------------------------------------------------------- + +export 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. + spawn("rundll32.exe", ["url.dll,FileProtocolHandler", safe], { detached: true, stdio: "ignore" }).unref(); + } else if (p === "darwin") { + spawn("open", [safe], { detached: true, stdio: "ignore" }).unref(); + } else { + spawn("xdg-open", [safe], { detached: true, stdio: "ignore" }).unref(); + } +} + +// --------------------------------------------------------------------------- +// 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. +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. + spawn("rundll32.exe", ["url.dll,FileProtocolHandler", filePath], { detached: true, stdio: "ignore" }).unref(); + } else if (p === "darwin") { + spawn("open", [filePath], { detached: true, stdio: "ignore" }).unref(); + } else { + spawn("xdg-open", [filePath], { detached: true, stdio: "ignore" }).unref(); + } +} + +// 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 }; + } + } + openPath(path); + return { ok: true, path }; +} diff --git a/extensions/connector-namespaces/mcp-unwrap-proxy.mjs b/extensions/connector-namespaces/mcp-unwrap-proxy.mjs new file mode 100644 index 000000000..ca5e41b83 --- /dev/null +++ b/extensions/connector-namespaces/mcp-unwrap-proxy.mjs @@ -0,0 +1,189 @@ +// MCP stdio <-> streamable-HTTP proxy that unwraps Azure Logic Apps "$content" envelopes. +// +// The Azure Connector-Gateway MCP endpoint returns post-initialize responses +// (tools/list, tools/call, ...) double-wrapped in a Logic Apps binary envelope: +// Content-Type: application/json +// { "$content-encoding": "identity", "$content-type": "text/event-stream", "$content": "" } +// A spec-compliant MCP client (the Copilot CLI) parses that JSON, finds no +// jsonrpc/id, and fails tools/list with a zod invalid_union error -> no tools load. +// +// This proxy speaks plain newline-delimited JSON-RPC to the CLI over stdio and +// forwards each request to the HTTPS endpoint, unwrapping the envelope (and the +// raw SSE framing) before emitting clean JSON-RPC back. It never opens the +// standalone GET SSE stream, so the endpoint's 404 on that stream is irrelevant. +// +// Config (env): +// MCP_TARGET_URL - required, the .../mcp endpoint URL +// MCP_API_KEY - optional, sent as X-API-Key +// MCP_HEADERS - optional JSON object of extra headers + +import { request as httpsRequest } from "node:https"; +import { createInterface } from "node:readline"; + +const targetUrl = process.env.MCP_TARGET_URL; +if (!targetUrl) { + process.stderr.write("[mcp-unwrap-proxy] MCP_TARGET_URL is required\n"); + process.exit(1); +} +// Defense in depth: the endpoint comes from an authenticated ARM read of the +// user's own gateway, but validate it anyway before we POST the API key. +// Requires https, rejects embedded credentials, and blocks obvious +// internal/link-local hosts. This is a string-level host check; it does not +// resolve DNS, which is acceptable given the value's trusted ARM origin. +function assertSafeTarget(u) { + if (u.protocol !== "https:") { + process.stderr.write("[mcp-unwrap-proxy] MCP_TARGET_URL must be https\n"); + process.exit(1); + } + if (u.username || u.password) { + process.stderr.write("[mcp-unwrap-proxy] MCP_TARGET_URL must not embed credentials\n"); + process.exit(1); + } + 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) { + process.stderr.write(`[mcp-unwrap-proxy] MCP_TARGET_URL host not allowed: ${host}\n`); + process.exit(1); + } +} + +const url = new URL(targetUrl); +assertSafeTarget(url); +const driver = httpsRequest; + +const baseHeaders = { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", +}; +if (process.env.MCP_API_KEY) baseHeaders["X-API-Key"] = process.env.MCP_API_KEY; +if (process.env.MCP_HEADERS) { + try { + Object.assign(baseHeaders, JSON.parse(process.env.MCP_HEADERS)); + } catch { + process.stderr.write("[mcp-unwrap-proxy] ignoring malformed MCP_HEADERS\n"); + } +} + +let sessionId = null; + +function emit(msg) { + process.stdout.write(JSON.stringify(msg) + "\n"); +} + +function log(s) { + process.stderr.write(`[mcp-unwrap-proxy] ${s}\n`); +} + +// Pull JSON-RPC messages out of an SSE body (data: lines, possibly multi-event). +function parseSse(body) { + const out = []; + for (const block of body.split(/\r?\n\r?\n/)) { + const data = block + .split(/\r?\n/) + .filter((l) => l.startsWith("data:")) + .map((l) => l.slice(5).trim()) + .join("\n"); + if (!data) continue; + try { + out.push(JSON.parse(data)); + } catch { + log(`could not parse SSE data chunk: ${data.slice(0, 120)}`); + } + } + return out; +} + +// Turn any HTTP response body into a list of JSON-RPC messages. +function extractMessages(contentType, body) { + const ct = (contentType || "").toLowerCase(); + if (ct.includes("text/event-stream")) return parseSse(body); + + let parsed; + try { + parsed = JSON.parse(body); + } catch { + return []; + } + // Logic Apps $content envelope -> decode and recurse on the inner payload. + if (parsed && typeof parsed === "object" && typeof parsed["$content"] === "string") { + const inner = Buffer.from(parsed["$content"], "base64").toString("utf8"); + const innerCt = parsed["$content-type"] || ""; + if (innerCt.includes("text/event-stream") || /^\s*event:|\bdata:/.test(inner)) { + return parseSse(inner); + } + try { + return [JSON.parse(inner)]; + } catch { + return []; + } + } + return [parsed]; +} + +function forward(line) { + let req; + try { + req = JSON.parse(line); + } catch { + return; + } + const isNotification = req.id === undefined || req.id === null; + + const headers = { ...baseHeaders }; + if (sessionId) headers["Mcp-Session-Id"] = sessionId; + + const r = driver( + { + method: "POST", + hostname: url.hostname, + port: url.port || undefined, + path: url.pathname + url.search, + headers, + }, + (res) => { + const sid = res.headers["mcp-session-id"]; + if (sid) sessionId = sid; + let body = ""; + res.setEncoding("utf8"); + res.on("data", (c) => (body += c)); + res.on("end", () => { + if (res.statusCode === 202 || body.length === 0) return; // accepted notification + if (res.statusCode >= 400) { + log(`HTTP ${res.statusCode} for ${req.method}: ${body.slice(0, 200)}`); + if (!isNotification) { + emit({ + jsonrpc: "2.0", + id: req.id, + error: { code: -32000, message: `Upstream HTTP ${res.statusCode}` }, + }); + } + return; + } + for (const msg of extractMessages(res.headers["content-type"], body)) emit(msg); + }); + }, + ); + r.on("error", (e) => { + log(`request error for ${req.method}: ${e}`); + if (!isNotification) { + emit({ jsonrpc: "2.0", id: req.id, error: { code: -32000, message: String(e) } }); + } + }); + r.write(line); + r.end(); +} + +const rl = createInterface({ input: process.stdin, crlfDelay: Infinity }); +rl.on("line", (line) => { + const trimmed = line.trim(); + if (trimmed) forward(trimmed); +}); +rl.on("close", () => process.exit(0)); diff --git a/extensions/connector-namespaces/package.json b/extensions/connector-namespaces/package.json new file mode 100644 index 000000000..4540a2900 --- /dev/null +++ b/extensions/connector-namespaces/package.json @@ -0,0 +1,19 @@ +{ + "name": "connector-namespaces", + "version": "1.0.0", + "type": "module", + "main": "extension.mjs", + "description": "Browse and add MCP connectors from your Azure Connector Namespace to your Copilot session — search by name or category, then add connectors to enable their tools.", + "keywords": [ + "azure", + "connector-namespace", + "mcp", + "mcp-connectors", + "model-context-protocol", + "tool-discovery" + ], + "license": "MIT", + "dependencies": { + "@github/copilot-sdk": "latest" + } +} diff --git a/extensions/connector-namespaces/renderer.mjs b/extensions/connector-namespaces/renderer.mjs new file mode 100644 index 000000000..16156a4f1 --- /dev/null +++ b/extensions/connector-namespaces/renderer.mjs @@ -0,0 +1,936 @@ +// Renderers for the connector namespace picker and connector catalog pages. +// Styled to match the reference connector extension UI. + +// 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) { + const subOptions = subscriptions.map((s) => + `` + ).join(""); + + return ` + +Select Connector Namespace${baseStyles()} + +
+

${brandMark(30, "setup")}Select a Connector Namespace

+
Choose which connector namespace to browse. This choice is saved for future sessions.
+
+
+ +
+
+ + +
+ +
+
Select a subscription to see available connector namespaces.
+
+`; +} + +// --------------------------------------------------------------------------- +// Catalog +// --------------------------------------------------------------------------- + +export function renderCatalogHtml(instanceId, catalog, { filter, category, source, config }) { + const byCategory = new Map(); + for (const c of catalog) { + if (!byCategory.has(c.category)) byCategory.set(c.category, []); + byCategory.get(c.category).push(c); + } + const sortedCats = [...byCategory.keys()].sort(); + + let sectionsHtml = ""; + for (const cat of sortedCats) { + const rows = byCategory.get(cat).sort((a, b) => a.displayName.localeCompare(b.displayName)); + const rowsHtml = rows.map((c) => { + const icon = c.iconUri + ? `
` + : `
${esc(c.displayName.charAt(0))}
`; + // Button state is hydrated client-side from /api/state on load. + const btn = ``; + const haystack = esc((c.displayName + " " + (c.description || "")).toLowerCase()); + return `
${icon}
${esc(c.displayName)}
${esc(c.description)}
${btn}
`; + }).join(""); + sectionsHtml += `
${esc(cat)}
${rowsHtml}
`; + } + + if (!catalog.length) { + sectionsHtml = `
No connectors available.
`; + } + + return ` + +Connectors${baseStyles()} + +
+
+

${brandMark(24, "cat")}Connectors

+ +
+
Add Microsoft and partner tools to this Copilot session.
+
Namespace ${esc(config.gatewayName)} \u00b7 RG ${esc(config.resourceGroup)}
+
+ + +
+
+
+ + + + +
+ + +${sectionsHtml} + +`; +} + +// --------------------------------------------------------------------------- +// Error +// --------------------------------------------------------------------------- + +export function renderErrorHtml(message) { + return ` +Error${baseStyles()} +

Error

+
${esc(message)}
+`; +} + +// --------------------------------------------------------------------------- +function esc(s) { + return String(s ?? "").replace(/[&<>"']/g, (c) => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); +} diff --git a/extensions/connector-namespaces/server.mjs b/extensions/connector-namespaces/server.mjs new file mode 100644 index 000000000..cef6540c2 --- /dev/null +++ b/extensions/connector-namespaces/server.mjs @@ -0,0 +1,384 @@ +// Loopback HTTP server — serves connector namespace picker + connector catalog UI. + +import { createServer } from "node:http"; +import { renderCatalogHtml, renderErrorHtml, renderSetupHtml } from "./renderer.mjs"; +import { renderCreateNamespaceHtml } from "./createPage.mjs"; +import { addConnector, removeConnector, getSessionConfig, saveConfig, clearConfig } from "./state.mjs"; +import { fetchCatalog, invalidateCache } from "./catalog.mjs"; +import { + listConnectorGateways, + listSubscriptions, + listResourceGroups, + listUserAssignedIdentities, + checkConnectorGatewayNameAvailable, + createResourceGroup, + createConnectorGateway, + buildGatewayIdentity, +} from "./armClient.mjs"; +import { installConnector, finishInstall, openInBrowser, openMcpConfigFile, getInstalledState, uninstallConnector, deleteConnection, prewarmMeta } from "./install.mjs"; + +const servers = new Map(); +const starting = new Map(); // instanceId → Promise while a server is binding +const gatewayCache = new Map(); +const pendingOAuth = new Map(); // connName → timestamp + +const PENDING_OAUTH_TTL_MS = 15 * 60 * 1000; +// Drop stale/abandoned consent markers so the map can't grow unbounded and a +// brand-new install of the same connection can't observe a stale "done". +function prunePendingOAuth() { + const now = Date.now(); + for (const [name, ts] of pendingOAuth) { + if (now - ts > PENDING_OAUTH_TTL_MS) pendingOAuth.delete(name); + } +} + +// Whether a connector was added during the life of THIS extension process. +// MCP tools are only loaded by the CLI at session start, so an install done +// after the process started isn't usable until the session restarts. A real +// session restart spawns a fresh process and resets this to false. `acked` +// lets the user dismiss the reminder for the rest of the process. +let pendingRestart = false; +let restartAcked = false; + +function parseBody(req) { + return new Promise((resolve) => { + let data = ""; + req.on("data", (chunk) => { data += chunk; }); + req.on("end", () => { + try { resolve(JSON.parse(data)); } + catch { resolve({}); } + }); + }); +} + +async function handleRequest(req, res, instanceId) { + const url = new URL(req.url, "http://localhost"); + + // --- API routes --- + + if (req.method === "POST" && url.pathname === "/api/add") { + const body = await parseBody(req); + const config = getSessionConfig(); + if (!config) { json(res, { added: false, reason: "no_config" }); return; } + const catalog = await fetchCatalog(config.subscriptionId, config.resourceGroup, config.gatewayName); + const connector = catalog.find((c) => c.id === body.id); + json(res, connector ? addConnector(connector) : { added: false, reason: "not_found" }); + return; + } + + if (req.method === "POST" && url.pathname === "/api/remove") { + const body = await parseBody(req); + json(res, removeConnector(body.id)); + return; + } + + if (req.method === "POST" && url.pathname === "/api/select-gateway") { + const body = await parseBody(req); + const { subscriptionId, resourceGroup, gatewayName } = body; + if (!subscriptionId || !resourceGroup || !gatewayName) { + json(res, { error: "missing_fields" }); + return; + } + saveConfig({ subscriptionId, resourceGroup, gatewayName }); + invalidateCache(); + json(res, { ok: true }); + return; + } + + if (req.method === "POST" && url.pathname === "/api/change-gateway") { + clearConfig(); + invalidateCache(); + json(res, { ok: true }); + return; + } + + if (req.method === "GET" && url.pathname === "/api/gateways") { + const subId = url.searchParams.get("subscriptionId"); + const all = url.searchParams.get("all") === "true"; + if (!subId) { json(res, { error: "missing subscriptionId" }); return; } + const cacheKey = `gw:${subId}:${all ? "all" : "top"}`; + if (gatewayCache.has(cacheKey)) { + const cached = gatewayCache.get(cacheKey); + json(res, { gateways: cached.items, hasMore: cached.hasMore, cached: true }); + return; + } + try { + const result = await listConnectorGateways(subId, { fetchAll: all }); + gatewayCache.set(cacheKey, result); + json(res, { gateways: result.items, hasMore: result.hasMore }); + } catch (err) { + json(res, { error: err.message }); + } + return; + } + + // --- Create connector namespace routes --- + + if (req.method === "GET" && url.pathname === "/api/resource-groups") { + const subId = url.searchParams.get("subscriptionId"); + if (!subId) { json(res, { error: "missing subscriptionId" }); return; } + try { + json(res, { resourceGroups: await listResourceGroups(subId) }); + } catch (err) { + json(res, { error: err.message }); + } + return; + } + + if (req.method === "GET" && url.pathname === "/api/identities") { + const subId = url.searchParams.get("subscriptionId"); + if (!subId) { json(res, { error: "missing subscriptionId" }); return; } + try { + json(res, { identities: await listUserAssignedIdentities(subId) }); + } catch (err) { + json(res, { error: err.message }); + } + return; + } + + if (req.method === "GET" && url.pathname === "/api/check-name") { + const subId = url.searchParams.get("subscriptionId"); + const resourceGroup = url.searchParams.get("resourceGroup"); + const name = url.searchParams.get("name"); + if (!subId || !resourceGroup || !name) { json(res, { error: "missing_fields" }); return; } + try { + json(res, { available: await checkConnectorGatewayNameAvailable(subId, resourceGroup, name) }); + } catch (err) { + json(res, { error: err.message }); + } + return; + } + + if (req.method === "POST" && url.pathname === "/api/create-namespace") { + const body = await parseBody(req); + const { subscriptionId, resourceGroup, createNewResourceGroup, region, name, enableSystemIdentity, userAssignedIds } = body; + if (!subscriptionId || !resourceGroup || !region || !name) { + json(res, { error: "missing_fields" }); + return; + } + try { + if (createNewResourceGroup) { + await createResourceGroup(subscriptionId, resourceGroup, region); + } + const identity = buildGatewayIdentity(!!enableSystemIdentity, Array.isArray(userAssignedIds) ? userAssignedIds : []); + await createConnectorGateway(subscriptionId, resourceGroup, name, { location: region, identity }); + saveConfig({ subscriptionId, resourceGroup, gatewayName: name }); + invalidateCache(); + gatewayCache.clear(); + json(res, { ok: true }); + } catch (err) { + json(res, { error: err.message }); + } + return; + } + + // --- Install flow routes --- + + if (req.method === "POST" && url.pathname === "/api/install") { + const body = await parseBody(req); + const config = getSessionConfig(); + if (!config) { json(res, { error: "no_config" }); return; } + const { apiName, displayName } = body; + if (!apiName) { json(res, { error: "missing apiName" }); return; } + const serverEntry = servers.get(instanceId); + const port = serverEntry ? new URL(serverEntry.url).port : "0"; + const callbackBase = `http://127.0.0.1:${port}/auth/callback/`; + try { + const result = await installConnector(config, apiName, displayName || apiName, callbackBase, "profile"); + if (result && !result.error && !result.needsConsent) { pendingRestart = true; restartAcked = false; } + json(res, result); + } catch (err) { + json(res, { error: err.message }); + } + return; + } + + if (req.method === "POST" && url.pathname === "/api/finish-install") { + const body = await parseBody(req); + const config = getSessionConfig(); + if (!config) { json(res, { error: "no_config" }); return; } + try { + const result = await finishInstall(config, body.apiName, body.displayName, body.connName, body.location, "profile"); + if (result && !result.error) { pendingRestart = true; restartAcked = false; } + json(res, result); + } catch (err) { + json(res, { error: err.message }); + } + return; + } + + if (req.method === "POST" && url.pathname === "/api/open-url") { + const body = await parseBody(req); + if (!body.url || !/^https?:\/\//.test(body.url)) { json(res, { error: "invalid url" }); return; } + openInBrowser(body.url); + json(res, { ok: true }); + return; + } + + if (req.method === "POST" && url.pathname === "/api/open-config") { + try { + const result = await openMcpConfigFile(); + json(res, result); + } catch (err) { + json(res, { ok: false, error: err.message }); + } + return; + } + + if (req.method === "GET" && url.pathname === "/oauth-status") { + prunePendingOAuth(); + const connName = url.searchParams.get("connectionName") || ""; + // Consume the marker once observed so the map self-cleans on the happy + // path instead of lingering until the TTL sweep. delete() returns true + // iff the marker existed, which is exactly the "done" signal. + const done = connName ? pendingOAuth.delete(connName) : false; + json(res, { done }); + return; + } + + if (req.method === "GET" && url.pathname === "/api/state") { + const config = getSessionConfig(); + if (!config) { json(res, { error: "no_config" }); return; } + try { + const state = await getInstalledState(config); + json(res, { state, pendingRestart: pendingRestart && !restartAcked }); + } catch (err) { + json(res, { error: err.message }); + } + return; + } + + if (req.method === "POST" && url.pathname === "/api/ack-restart") { + restartAcked = true; + json(res, { ok: true }); + return; + } + + if (req.method === "POST" && url.pathname === "/api/uninstall") { + const body = await parseBody(req); + const config = getSessionConfig(); + if (!config) { json(res, { error: "no_config" }); return; } + if (!body.apiName) { json(res, { error: "missing apiName" }); return; } + try { + const result = await uninstallConnector(config, body.apiName); + json(res, result); + } catch (err) { + json(res, { error: err.message }); + } + return; + } + + // Roll back a connection orphaned by a cancelled install (no config exists + // yet, so /api/uninstall can't find it — delete the connection directly). + if (req.method === "POST" && url.pathname === "/api/rollback-connection") { + const body = await parseBody(req); + const config = getSessionConfig(); + if (!config) { json(res, { error: "no_config" }); return; } + if (!body.connName) { json(res, { error: "missing connName" }); return; } + try { + const result = await deleteConnection(config, body.connName); + json(res, result); + } catch (err) { + json(res, { error: err.message }); + } + return; + } + + if (req.method === "GET" && url.pathname.startsWith("/auth/callback/")) { + const connName = decodeURIComponent(url.pathname.slice("/auth/callback/".length)); + prunePendingOAuth(); + if (connName) pendingOAuth.set(connName, Date.now()); + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(`Sign-in complete

Sign-in complete

You can close this tab and return to Copilot.

`); + return; + } + + // --- Page routes --- + + const config = getSessionConfig(); + + // Create connector namespace page (reachable with or without a saved config) + if (url.pathname === "/create") { + try { + const subs = await listSubscriptions(); + const preselected = url.searchParams.get("subscriptionId") || ""; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(renderCreateNamespaceHtml(subs, preselected)); + } catch (err) { + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(renderErrorHtml(`Failed to load subscriptions. Sign in to Azure when the browser opens, then reload this page.\n\n${err.message}`)); + } + return; + } + + // Setup page (no gateway configured) + if (!config || url.pathname === "/setup") { + try { + const subs = await listSubscriptions(); + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(renderSetupHtml(subs)); + } catch (err) { + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(renderErrorHtml(`Failed to load subscriptions. Sign in to Azure when the browser opens, then reload this page.\n\n${err.message}`)); + } + return; + } + + // Catalog page + const filter = url.searchParams.get("filter") || ""; + const category = url.searchParams.get("category") || ""; + const source = url.searchParams.get("source") || ""; + + try { + const catalog = await fetchCatalog(config.subscriptionId, config.resourceGroup, config.gatewayName); + // Warm connector metadata (opId + connection params) in the background so + // the Connect click doesn't pay for the slow swagger export. + prewarmMeta(config, catalog.map((c) => c.apiName)); + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(renderCatalogHtml(instanceId, catalog, { filter, category, source, config })); + } catch (err) { + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(renderErrorHtml(`Failed to fetch catalog: ${err.message}`)); + } +} + +function json(res, data) { + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(data)); +} + +export function startServer(instanceId) { + const existing = servers.get(instanceId); + if (existing) return Promise.resolve(existing); + const inflight = starting.get(instanceId); + if (inflight) return inflight; + + const p = (async () => { + const server = createServer((req, res) => handleRequest(req, res, instanceId)); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + const entry = { server, url: `http://127.0.0.1:${port}/` }; + servers.set(instanceId, entry); + return entry; + })(); + // Record the in-flight start synchronously so a concurrent open() for the + // same instance awaits this server instead of binding a second one and + // leaking the first. + starting.set(instanceId, p); + p.finally(() => { if (starting.get(instanceId) === p) starting.delete(instanceId); }); + return p; +} + +export async function stopServer(instanceId) { + const inflight = starting.get(instanceId); + if (inflight) { try { await inflight; } catch { /* start failed; nothing to close */ } } + const entry = servers.get(instanceId); + if (entry) { + servers.delete(instanceId); + // close() never resolves while the iframe holds a keep-alive socket — + // drop live connections first so onClose can't hang and leak the process. + if (typeof entry.server.closeAllConnections === "function") entry.server.closeAllConnections(); + await new Promise((resolve) => entry.server.close(() => resolve())); + } +} diff --git a/extensions/connector-namespaces/state.mjs b/extensions/connector-namespaces/state.mjs new file mode 100644 index 000000000..1087a3e91 --- /dev/null +++ b/extensions/connector-namespaces/state.mjs @@ -0,0 +1,102 @@ +// State management — persists gateway config and tracks added connectors. + +import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; + +const STORAGE_DIR = join(process.env.COPILOT_HOME || join(homedir(), ".copilot"), "extensions", "connector-namespaces", "artifacts"); +const CONFIG_FILE = join(STORAGE_DIR, "gateway-config.json"); + +// In-memory state +const addedConnectors = new Map(); +let sessionConfig = null; + +// --------------------------------------------------------------------------- +// Persistent config (gateway selection) +// --------------------------------------------------------------------------- + +function ensureStorageDir() { + if (!existsSync(STORAGE_DIR)) { + mkdirSync(STORAGE_DIR, { recursive: true }); + } +} + +function isValidConfig(data) { + return ( + data != null && + typeof data === "object" && + typeof data.subscriptionId === "string" && data.subscriptionId.length > 0 && + typeof data.resourceGroup === "string" && data.resourceGroup.length > 0 && + typeof data.gatewayName === "string" && data.gatewayName.length > 0 + ); +} + +export function loadSavedConfig() { + try { + if (existsSync(CONFIG_FILE)) { + const data = JSON.parse(readFileSync(CONFIG_FILE, "utf-8")); + // Only accept a fully-formed config. A shapeless or empty object + // (e.g. the legacy "{}" an old clearConfig used to write) must not + // masquerade as a valid selection, or the picker gets skipped and + // the catalog is fetched with missing coordinates. + if (isValidConfig(data)) { + sessionConfig = data; + return data; + } + } + } catch { /* ignore corrupt file */ } + sessionConfig = null; + return null; +} + +export function saveConfig(config) { + ensureStorageDir(); + sessionConfig = config; + writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8"); +} + +export function clearConfig() { + sessionConfig = null; + // Remove the file outright rather than leaving a "{}" stub that a later + // loadSavedConfig could misread as a valid selection. + try { if (existsSync(CONFIG_FILE)) unlinkSync(CONFIG_FILE); } catch { /* ignore */ } +} + +export function setSessionConfig(config) { + sessionConfig = config; +} + +export function getSessionConfig() { + return sessionConfig; +} + +// --------------------------------------------------------------------------- +// Added connectors (session-only, not persisted) +// --------------------------------------------------------------------------- + +export function getAddedConnectors() { + return [...addedConnectors.values()]; +} + +export function addConnector(connector) { + if (addedConnectors.has(connector.id)) { + return { added: false, reason: "already_added" }; + } + addedConnectors.set(connector.id, { + connector, + addedAt: new Date().toISOString(), + }); + return { added: true, connector: connector.displayName }; +} + +export function removeConnector(connectorId) { + if (!addedConnectors.has(connectorId)) { + return { removed: false, reason: "not_found" }; + } + addedConnectors.delete(connectorId); + return { removed: true }; +} + +export function isConnectorAdded(connectorId) { + return addedConnectors.has(connectorId); +} From 1c85b675a607c64a61fff6a56bdbdc333fd0cade Mon Sep 17 00:00:00 2001 From: Alex Yang Date: Wed, 8 Jul 2026 17:14:31 -0700 Subject: [PATCH 2/4] Update Connector Namespaces canvas extension Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- extensions/connector-namespaces/README.md | 117 +++- extensions/connector-namespaces/catalog.mjs | 3 +- .../connector-namespaces/categories.mjs | 15 + .../copilot-extension.json | 4 + extensions/connector-namespaces/install.mjs | 214 ++++++- .../install.reauth.test.mjs | 108 ++++ .../connector-namespaces/install.test.mjs | 202 ++++++ .../connector-namespaces/preview/.gitignore | 2 + .../connector-namespaces/preview/README.md | 112 ++++ .../connector-namespaces/preview/fixtures.mjs | 116 ++++ .../connector-namespaces/preview/server.mjs | 151 +++++ .../connector-namespaces/preview/shots.mjs | 96 +++ extensions/connector-namespaces/renderer.mjs | 606 ++++++++++++++---- .../connector-namespaces/renderer.test.mjs | 279 ++++++++ extensions/connector-namespaces/server.mjs | 105 ++- .../connector-namespaces/server.test.mjs | 78 +++ .../connector-namespaces/test/README.md | 131 ++++ .../connector-namespaces/test/mcp-probe.mjs | 206 ++++++ .../connector-namespaces/test/safe-tools.mjs | 166 +++++ .../connector-namespaces/test/smoke.mjs | 311 +++++++++ 20 files changed, 2863 insertions(+), 159 deletions(-) create mode 100644 extensions/connector-namespaces/categories.mjs create mode 100644 extensions/connector-namespaces/copilot-extension.json create mode 100644 extensions/connector-namespaces/install.reauth.test.mjs create mode 100644 extensions/connector-namespaces/install.test.mjs create mode 100644 extensions/connector-namespaces/preview/.gitignore create mode 100644 extensions/connector-namespaces/preview/README.md create mode 100644 extensions/connector-namespaces/preview/fixtures.mjs create mode 100644 extensions/connector-namespaces/preview/server.mjs create mode 100644 extensions/connector-namespaces/preview/shots.mjs create mode 100644 extensions/connector-namespaces/renderer.test.mjs create mode 100644 extensions/connector-namespaces/server.test.mjs create mode 100644 extensions/connector-namespaces/test/README.md create mode 100644 extensions/connector-namespaces/test/mcp-probe.mjs create mode 100644 extensions/connector-namespaces/test/safe-tools.mjs create mode 100644 extensions/connector-namespaces/test/smoke.mjs diff --git a/extensions/connector-namespaces/README.md b/extensions/connector-namespaces/README.md index 18728ffc2..2754e5011 100644 --- a/extensions/connector-namespaces/README.md +++ b/extensions/connector-namespaces/README.md @@ -1,41 +1,112 @@ -# MCP Connectors +# MCP Connectors — Copilot CLI Canvas Extension -A Copilot CLI canvas extension for browsing and adding [Model Context Protocol (MCP)](https://modelcontextprotocol.io) connectors from an Azure Connector Namespace directly inside your Copilot session. +A GitHub Copilot CLI **canvas extension** that lets you browse and add MCP +connectors from an Azure **Connector Namespace** directly inside a Copilot CLI +session. Search by name or category, sign in to a connector, and its tools +become available to the agent — without leaving the terminal. -![MCP Connectors canvas](assets/preview.png) +> The canvas talks to public Azure Resource Manager (`management.azure.com`) +> using a token from an interactive Azure sign-in — a browser tab opens once per +> session. No client secret is embedded in the extension; the refresh token is +> cached locally (owner-only) so you are not prompted every session. -## What it does +## Prerequisites -Opens an interactive canvas in Copilot where you can: +- **GitHub Copilot CLI** (the host that loads canvas extensions). +- **An Azure account.** The first time the canvas loads subscriptions, a browser + tab opens for Microsoft sign-in; after that the token is renewed silently for + the session. No Azure CLI required. +- **An Azure subscription with a Connector Namespace** — resource type + `Microsoft.Web/connectorGateways` (API version `2026-05-01-preview`). This is + a preview resource provider; you must have access to it for the catalog to + load. Without it the extension installs fine but has nothing to show. -- Browse the MCP connectors published to your Azure Connector Namespace. -- Search and filter connectors by name or category. -- Add a connector to your session with one click to enable its tools. +## Install -Once added, the connector's MCP tools become available to Copilot for the rest of your session. +This extension lives in the private `serverless-paas-balam/polaris` repo, so +installing it pulls the files through GitHub's API. Before you start, your +Copilot CLI / `gh` login must be a member of the `serverless-paas-balam` org +**with SAML SSO authorized**. Verify it in two seconds: -## Prerequisites +``` +gh api /repos/serverless-paas-balam/polaris --jq .visibility +``` -- An Azure account with access to a **Connector Namespace** (the `Microsoft.Web/connectorGateways` resource). If you don't have one yet, the canvas links you to creating one. -- A modern web browser for sign-in. +If that prints `internal` (or `private`) you're set. If it returns `404`, your +token isn't authorized yet — run `gh auth login`, approve the SSO prompt for the +org, and retry. (GitHub returns `404`, not a permission error, when gating +private-repo reads, so don't let the status code fool you.) -You do **not** need the Azure CLI installed. The extension signs you in through your browser the first time it needs to talk to Azure. +### Option A — from the repo (recommended) -## Usage +Track `main` to get the latest committed version: + +``` +install_extension https://github.com/serverless-paas-balam/polaris/tree/main/.github/extensions/connector-namespaces +``` -1. Install the extension and open the **MCP Connectors** canvas in Copilot. -2. The first time the canvas needs Azure, a browser tab opens for sign-in. Complete sign-in and return to Copilot. -3. Pick the subscription that holds your Connector Namespace. -4. Browse or search the available connectors and add the ones you want. +For a reproducible pin, swap `main` for a release tag — but only one cut +**after** this extension landed on `main`. Run `git tag -l "v*"` and pick the +newest. Tags `v1.4.0` and earlier predate this folder and will `404`, so don't +pin to them. For an internal dev tool, tracking `main` is the safe default. -If a subscription has no Connector Namespace, the canvas shows a short setup guide instead of an empty list. +The destination **scope** is chosen at install time: -## How sign-in works +- **user** (default) — installs globally for you at + `$COPILOT_HOME/extensions/connector-namespaces/`. The usual choice for a + personal tool. +- **project** — installs into the current repo. +- **session** — scoped to a single CLI session. -Authentication uses the standard OAuth 2.0 authorization-code flow with PKCE against the well-known Azure CLI public client. A short-lived loopback HTTP listener captures the redirect, exchanges the code for an Azure Resource Manager token, and caches it for reuse. No client secret is used. The refresh token is cached under your Copilot home directory (`~/.copilot/extensions/connector-namespaces/artifacts/auth-cache.json`, written with owner-only `0600` permissions) so you are not prompted to sign in every session. Tokens are refreshed silently until the refresh token itself expires, after which you sign in once more. +### Option B — from a gist (no org SSO needed) + +If you can't SSO-authorize the org, a gist sidesteps the private-repo wall. The +maintainer publishes a secret gist via **"Share extension as gist…"**; anyone +with the link installs it with **"Install extension from gist…"** or: + +``` +install_extension https://gist.github.com// +``` + +## Usage -All Azure Resource Manager calls are restricted to the public ARM endpoint (`management.azure.com`); the extension does not contact any other host. +1. Open the **MCP Connectors** canvas from Copilot CLI. +2. On first run a browser tab opens for Microsoft sign-in; complete it and the + canvas loads your subscriptions. Pick an Azure **subscription** and a + **Connector Namespace**. The choice is saved for future sessions (change it + any time via **Change namespace**). +3. Browse or filter the connector catalog, then **Connect**. A browser tab + opens for Microsoft sign-in; complete it and the canvas updates on its own. +4. Connected connectors are added to your CLI session so the agent can use + their tools. + +## How it works + +- `extension.mjs` — entry point; declares the canvas and a few agent-facing + actions (`add_connector`, `remove_connector`, `list_connectors`). +- `server.mjs` — a loopback HTTP server (bound to `127.0.0.1` only) that serves + the canvas UI and the JSON/OAuth endpoints the iframe calls. +- `armClient.mjs` — thin ARM client (token via interactive Azure sign-in, public + ARM base only, SSRF-guarded path segments). +- `catalog.mjs` — fetches and curates the connector list for a namespace. +- `install.mjs` — the connect/install pipeline (managed-API connection, consent, + best-effort rollback on cancel). +- `renderer.mjs` — all canvas HTML/CSS/client JS. +- `state.mjs` / `actions/` — saved-config persistence and the agent action + handlers. + +## Privacy & security + +- Tokens are obtained through an interactive Azure sign-in (OAuth2 auth-code + with PKCE); no client secret is used. The refresh token is cached under your + Copilot home directory + (`~/.copilot/extensions/connector-namespaces/artifacts/auth-cache.json`, + written with owner-only `0600` permissions) so you are not prompted to sign in + every session; access tokens are held in memory and never logged. +- All servers bind to loopback (`127.0.0.1`) and are never exposed externally. +- ARM requests go only to `https://management.azure.com/`; path segments are + validated to prevent SSRF-style host smuggling. ## License -[MIT](LICENSE) +[MIT](./LICENSE) © Microsoft Corporation. diff --git a/extensions/connector-namespaces/catalog.mjs b/extensions/connector-namespaces/catalog.mjs index d3840bd47..79f4165cb 100644 --- a/extensions/connector-namespaces/catalog.mjs +++ b/extensions/connector-namespaces/catalog.mjs @@ -10,6 +10,7 @@ // 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 || ""; @@ -28,7 +29,7 @@ function categoryFor(name, displayName) { d.startsWith("microsoft") || d.startsWith("work iq") || d.startsWith("dynamics 365"); - return isMicrosoft ? "Microsoft" : "Partner connectors"; + return isMicrosoft ? CATEGORY.microsoft : CATEGORY.partner; } let cachedCatalog = null; diff --git a/extensions/connector-namespaces/categories.mjs b/extensions/connector-namespaces/categories.mjs new file mode 100644 index 000000000..57cd9592f --- /dev/null +++ b/extensions/connector-namespaces/categories.mjs @@ -0,0 +1,15 @@ +// Catalog category values. +// +// `category` is a routing key, not display text: the renderer partitions catalog +// items into the Microsoft vs Partners sections by comparing against these exact +// values (see renderCatalogHtml in renderer.mjs). Kept as a frozen enum here, +// rather than free-form strings scattered across the producer, renderer, and +// tests, so the routing contract lives in one place. +// +// Zero-dependency on purpose: renderer.mjs imports this, and renderer.test.mjs +// loads renderer.mjs as a pure string-rendering gate. Sourcing the enum from +// catalog.mjs instead would drag armClient.mjs (the ARM SDK) into that gate. +export const CATEGORY = Object.freeze({ + microsoft: "Microsoft", + partner: "Partners", +}); diff --git a/extensions/connector-namespaces/copilot-extension.json b/extensions/connector-namespaces/copilot-extension.json new file mode 100644 index 000000000..3949dbe48 --- /dev/null +++ b/extensions/connector-namespaces/copilot-extension.json @@ -0,0 +1,4 @@ +{ + "name": "connector-namespaces", + "version": 1 +} diff --git a/extensions/connector-namespaces/install.mjs b/extensions/connector-namespaces/install.mjs index f13648c5c..0f101d6c9 100644 --- a/extensions/connector-namespaces/install.mjs +++ b/extensions/connector-namespaces/install.mjs @@ -73,6 +73,13 @@ function assertSafeMcpTarget(rawUrl) { // 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" }; @@ -82,21 +89,30 @@ async function arm(method, url, body) { // redirect the call off ARM. assertArmHost throws unless fullUrl targets // https://management.azure.com/. const safeUrl = assertArmHost(fullUrl); - 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) { + + 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; - throw err; + + // 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 } - return parsed; } // DELETE that tolerates "already gone" (404) but surfaces every other failure @@ -261,14 +277,14 @@ export async function createConnection(config, apiName, displayName, location) { export async function getConsentUrl(config, connName, callbackUrl, oauthParam) { const param = oauthParam || { parameterName: "token", redirectUrl: callbackUrl }; - const res = await arm("POST", `${gatewayId(config)}/connections/${connName}/listConsentLinks?api-version=${API_VERSION}`, { + 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/${connName}?api-version=${API_VERSION}`); + const conn = await arm("GET", `${gatewayId(config)}/connections/${armSegment(connName)}?api-version=${API_VERSION}`); return conn?.properties?.statuses?.[0]?.status ?? conn?.properties?.overallStatus ?? "Unknown"; } @@ -310,7 +326,7 @@ export async function mintApiKey(config, configName) { } export async function getMcpEndpointUrl(config, configName) { - const cfg = await arm("GET", `${gatewayId(config)}/mcpserverConfigs/${configName}?api-version=${API_VERSION}`); + const cfg = await arm("GET", `${gatewayId(config)}/mcpserverConfigs/${armSegment(configName)}?api-version=${API_VERSION}`); return cfg?.properties?.mcpEndpointUrl || null; } @@ -321,8 +337,15 @@ export async function getMcpEndpointUrl(config, configName) { async function readMcpConfigAt(path) { try { const raw = await fs.readFile(path, "utf8"); - const parsed = JSON.parse(raw); - if (!parsed.mcpServers || typeof parsed.mcpServers !== "object") parsed.mcpServers = {}; + 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: {} }; @@ -330,7 +353,7 @@ async function readMcpConfigAt(path) { } } -export async function writeMcpEntry(name, url, key, scope = "profile") { +export async function writeMcpEntry(name, url, key, scope = "profile", meta = null) { assertSafeMcpTarget(url); const path = mcpConfigPath(scope); return withConfigLock(async () => { @@ -342,6 +365,10 @@ export async function writeMcpEntry(name, url, key, scope = "profile") { args: [MCP_PROXY_PATH], env: { MCP_TARGET_URL: url, MCP_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; // The file holds a long-lived API key — keep it and its directory // owner-only. chmod re-asserts 0600 when the file already existed // (writeFile mode only applies on create); it's a benign no-op on Windows. @@ -406,6 +433,18 @@ export async function uninstallConnector(config, apiName) { 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 }; + if (entry.configName) await removeMcpEntry(entry.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 @@ -472,14 +511,119 @@ export async function finishInstall(config, apiName, displayName, connName, loca } if (!endpointUrl) throw new Error(`MCP endpoint URL not available (connection status: ${status}).`); - // Mint key and write the CLI entry. + // 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); - await writeMcpEntry(configName, endpointUrl, key, scope); + const gw = gatewayId(config); + await writeMcpEntry(configName, endpointUrl, key, scope, { + gatewayId: gw, + mcpServerConfigId: `${gw}/mcpserverConfigs/${armSegment(configName)}`, + connectionId: `${gw}/connections/${armSegment(connName)}`, + apiName, + }); const warning = status === "Connected" ? undefined : `Connection ended in state "${status}". You may need to re-authenticate.`; return { ok: true, configName, connName, endpointUrl, scope, warning }; } +// --------------------------------------------------------------------------- +// 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") { + const state = await getInstalledState(config); + const entry = state[apiName]; + 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); + } + + const location = await getGatewayLocation(config); + const meta = await loadConnectorMeta(config, apiName, location); + const callbackUrl = `${callbackBase}${encodeURIComponent(connName)}`; + 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) { + if (entry.configName) { + await armDelete(`${gatewayId(config)}/mcpserverConfigs/${armSegment(entry.configName)}?api-version=${API_VERSION}`).catch(() => {}); + await removeMcpEntry(entry.configName).catch(() => {}); + } + await deleteConnection(config, connName).catch(() => {}); + return installConnector(config, apiName, displayName, callbackBase, scope); + } + 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 }; + } + + // 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). + let status = "Unknown"; + for (let i = 0; i < 20; i++) { + status = await getConnectionStatus(config, connName); + if (status === "Connected") break; + await sleep(1000); + } + + // 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, + }); + + const warning = status === "Connected" ? undefined : `Connection ended in state "${status}". You may need to re-authenticate.`; + return { ok: true, configName, connName, endpointUrl, scope, warning, reauth: true }; +} + // --------------------------------------------------------------------------- // Installed-state derivation (source of truth = the gateway + CLI config) // --------------------------------------------------------------------------- @@ -499,9 +643,18 @@ export async function getInstalledState(config) { const profileKeys = new Set(Object.keys(profileCfg.mcpServers ?? {})); const workspaceKeys = new Set(Object.keys(workspaceCfg.mcpServers ?? {})); - // Map apiName -> install state. - const byApi = {}; - for (const cfg of configsRes.value ?? []) { + 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; @@ -510,7 +663,7 @@ export async function getInstalledState(config) { const connectionStatus = conn?.properties?.statuses?.[0]?.status ?? conn?.properties?.overallStatus ?? "Unknown"; const inWorkspace = workspaceKeys.has(cfg.name); const inProfile = profileKeys.has(cfg.name); - byApi[apiName] = { + (candidatesByApi[apiName] ??= []).push({ installed: true, configName: cfg.name, connectionName: connName || null, @@ -518,7 +671,20 @@ export async function getInstalledState(config) { 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. First-seen + // wins ties so the choice is stable across refreshes. 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)) { + let best = list[0]; + for (const e of list) if (rank(e) > rank(best)) best = e; + // Internal-only signal for logging; the renderer ignores unknown fields. + byApi[apiName] = list.length > 1 ? { ...best, _configCount: list.length } : best; } return byApi; } diff --git a/extensions/connector-namespaces/install.reauth.test.mjs b/extensions/connector-namespaces/install.reauth.test.mjs new file mode 100644 index 000000000..933e887c6 --- /dev/null +++ b/extensions/connector-namespaces/install.reauth.test.mjs @@ -0,0 +1,108 @@ +// 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 .github/extensions/connector-namespaces/install.reauth.test.mjs + +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +// Isolate the process home BEFORE importing install.mjs. PROFILE_MCP_PATH +// (homedir-based) and armClient's TOKEN_DIR (COPILOT_HOME-based) are both bound at +// module-eval time, so the env vars must be set first. Seed a non-expiring fake +// token so getToken() never hits the network or an interactive sign-in, and a +// profile mcp-config so the local config 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 artifactsDir = join(TMP, "extensions", "connector-namespaces", "artifacts"); +mkdirSync(artifactsDir, { recursive: true }); +writeFileSync( + join(artifactsDir, "auth-cache.json"), + JSON.stringify({ token: "fake-token", refreshToken: "fake-refresh", expiresAt: Date.now() + 3600e3 }), +); + +const dotCopilot = join(TMP, ".copilot"); +mkdirSync(dotCopilot, { recursive: true }); +writeFileSync( + join(dotCopilot, "mcp-config.json"), + JSON.stringify({ mcpServers: { "docusign-bbb": { type: "http", url: "https://example/mcp" } } }), +); + +// Dynamic import AFTER the env is set. A static top-level import would be hoisted +// and evaluate install.mjs (binding the paths to the real home) before the env +// assignments run. +const { reauthConnector } = 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) }); + const fail = (status) => ({ ok: false, status, text: async () => "" }); + + if (method === "POST" && url.includes("/listConsentLinks")) return ok({ value: [{ link: "https://consent.example/redir" }] }); + if (url.includes("/managedApis/")) return fail(404); // meta -> null -> fallback oauth param + 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="); + + // 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.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", + ); +}); diff --git a/extensions/connector-namespaces/install.test.mjs b/extensions/connector-namespaces/install.test.mjs new file mode 100644 index 000000000..d8e5a612c --- /dev/null +++ b/extensions/connector-namespaces/install.test.mjs @@ -0,0 +1,202 @@ +// Regression guards for install-state selection. +// +// Run: node --test .github/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, first-seen 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 } 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 sig = `export async function ${name}(`; + const start = source.indexOf(sig); + 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); +}); + +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("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"); +}); + +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/preview/.gitignore b/extensions/connector-namespaces/preview/.gitignore new file mode 100644 index 000000000..0bac4c45f --- /dev/null +++ b/extensions/connector-namespaces/preview/.gitignore @@ -0,0 +1,2 @@ +# Screenshot evidence is throwaway, regenerated on demand by shots.mjs. +shots/ diff --git a/extensions/connector-namespaces/preview/README.md b/extensions/connector-namespaces/preview/README.md new file mode 100644 index 000000000..ad79e89bc --- /dev/null +++ b/extensions/connector-namespaces/preview/README.md @@ -0,0 +1,112 @@ +# connector-namespaces preview harness + +A standalone way to **see** every canvas state without launching the Copilot +app. It imports the real, pure renderer functions from `../renderer.mjs` and +serves each state on a fixed loopback port, with every `/api/*` endpoint stubbed +so you can force the states that keep regressing (the connecting spinner and the +"Restart your Copilot session…" banner). + +This exists because those two bugs have each shipped multiple times: + +- the sign-in spinner freezing (an unscoped `animation:none` leaking out of the + reduced-motion block), and +- the restart-banner dismiss button doing nothing (a CSS specificity bug that + let `.restart-banner{display:flex}` beat `[hidden]`). + +Both are static CSS facts, so the **deterministic gate is `../renderer.test.mjs`** +(run with `node --test`). This harness is the human-visual layer on top of it: +load a state in a browser, or capture screenshots with `agent-browser`. + +## Run the preview server + +```sh +node .github/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 .github/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 .github/extensions/connector-namespaces/renderer.test.mjs +``` + +Keep that green; use this harness to eyeball changes. diff --git a/extensions/connector-namespaces/preview/fixtures.mjs b/extensions/connector-namespaces/preview/fixtures.mjs new file mode 100644 index 000000000..d7cf6c57a --- /dev/null +++ b/extensions/connector-namespaces/preview/fixtures.mjs @@ -0,0 +1,116 @@ +// 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 = { + gatewayName: "contoso-ns", + resourceGroup: "rg-connectors", +}; + +// Catalog tiles. Shape per item: { category, displayName, apiName, description, +// iconUri?, brandColor? }. At least one item must be connectable so the +// connect -> spinner flow can be exercised. +// +// The renderer routes items by category: exactly `category === CATEGORY.microsoft` +// lands in the Microsoft section, everything else in Partners. Keep a mix of +// both here so the preview exercises the full 3-section layout (My MCPs / +// Microsoft / Partners) rather than dumping every tile into one section. +export const catalog = [ + { + category: CATEGORY.microsoft, + displayName: "Microsoft Teams", + apiName: "teams", + description: "Send messages, manage chats and channels.", + brandColor: "#5059c9", + }, + { + category: CATEGORY.microsoft, + displayName: "Outlook Mail", + apiName: "outlook", + description: "Read, send, and organize email.", + brandColor: "#0a66c2", + }, + { + category: CATEGORY.microsoft, + displayName: "SharePoint", + apiName: "sharepoint", + description: "Browse sites, lists, and documents.", + brandColor: "#038387", + }, + { + category: CATEGORY.partner, + displayName: "GitHub", + apiName: "github", + description: "Manage repos, issues, and pull requests.", + brandColor: "#24292e", + }, + { + category: CATEGORY.partner, + displayName: "Stripe", + apiName: "stripe", + description: "Payments, customers, and invoices.", + brandColor: "#635bff", + }, +]; + +// /api/state -> { state: { apiName: InstallState }, pendingRestart } +// InstallState: { installed, connectionStatus, inCli, cliPath?, cliScope? } +// Default state: nothing installed, no pending restart. The catalog renders +// every tile with a "Connect" button. +export const stateEmpty = { + state: {}, + pendingRestart: false, +}; + +// One connector already added (shows "Added" + Remove), restart pending so the +// banner is visible on load. Drives both the "added" tile and the banner state. +export const stateInstalledRestart = { + state: { + sharepoint: { + installed: true, + connectionStatus: "Connected", + inCli: true, + cliPath: "~/.copilot/mcp-config.json", + cliScope: "profile", + }, + }, + pendingRestart: true, +}; + +// Install response that forces the connecting flow. needsConsent keeps the +// sign-in modal (with the .si-spin spinner) open; /oauth-status then stays +// pending so the spinner keeps animating for a screenshot. +export const installNeedsConsent = { + needsConsent: true, + connName: "preview-conn", + consentUrl: "http://127.0.0.1:7331/fake-consent", + location: "eastus", +}; diff --git a/extensions/connector-namespaces/preview/server.mjs b/extensions/connector-namespaces/preview/server.mjs new file mode 100644 index 000000000..69da26dc0 --- /dev/null +++ b/extensions/connector-namespaces/preview/server.mjs @@ -0,0 +1,151 @@ +// Standalone preview server for the connector-namespaces connector catalog. +// +// Renders every canvas state with no Copilot app, no ARM, and no real OAuth by +// importing the *pure* HTML builders from renderer.mjs and stubbing every +// /api/* endpoint the inline client script calls. Point any browser (or the +// agent-browser driver in shots.mjs) at it to see exactly what ships. +// +// Run: node .github/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, "ConsentFake Microsoft consent page (preview). Close this tab."); + } + + // --- Stubbed API endpoints ---------------------------------------------- + if (req.method === "GET" && path === "/api/state") { + return sendJson(res, activeState); + } + if (req.method === "GET" && path === "/api/gateways") { + return sendJson(res, { gateways: fixtures.gateways, hasMore: false }); + } + if (req.method === "GET" && path === "/oauth-status") { + // Stay pending forever so the connecting spinner keeps animating for a + // screenshot. Flip to { done: true } if you want the full success flow. + return sendJson(res, { done: false }); + } + + if (req.method === "POST") { + await readBody(req); + switch (path) { + case "/api/select-gateway": + return sendJson(res, { ok: true }); + case "/api/install": + return sendJson(res, fixtures.installNeedsConsent); + case "/api/finish-install": + activeState = { ...activeState, pendingRestart: true }; + return sendJson(res, { ok: true }); + case "/api/ack-restart": + activeState = { ...activeState, pendingRestart: false }; + return sendJson(res, { ok: true }); + case "/api/uninstall": + return sendJson(res, { ok: true }); + case "/api/open-url": + // Preview no-op: do NOT actually launch a browser tab. + return sendJson(res, { ok: true }); + case "/api/rollback-connection": + return sendJson(res, { ok: true }); + default: + return sendJson(res, { error: `unstubbed POST ${path}` }, 404); + } + } + + res.statusCode = 404; + res.end("not found"); +}); + +server.on("error", (err) => { + if (err.code === "EADDRINUSE") { + console.error(`Port ${PORT} is already in use. Stop the other process or change PORT in server.mjs.`); + process.exit(1); + } + throw err; +}); + +server.listen(PORT, HOST, () => { + console.log(`canvas preview server: http://${HOST}:${PORT}/`); + console.log(" / catalog (empty state)"); + console.log(" /?restart=1 catalog with restart banner visible"); + console.log(" /?installed=1 catalog with one connector added"); + console.log(" /setup namespace picker"); + console.log(" /error error state"); + console.log("Press Ctrl+C to stop."); +}); diff --git a/extensions/connector-namespaces/preview/shots.mjs b/extensions/connector-namespaces/preview/shots.mjs new file mode 100644 index 000000000..fb47ad4b5 --- /dev/null +++ b/extensions/connector-namespaces/preview/shots.mjs @@ -0,0 +1,96 @@ +// agent-browser screenshot driver for the canvas preview server. +// +// Captures every canvas state to ./shots/ and drives the two interaction flows +// that keep regressing: +// 1. catalog -> click Connect -> sign-in modal with the spinning .si-spin +// 2. restart banner visible -> click dismiss -> banner gone +// +// Requires the preview server to be running: +// node .github/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 .github/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 .github/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 index 16156a4f1..10417eb60 100644 --- a/extensions/connector-namespaces/renderer.mjs +++ b/extensions/connector-namespaces/renderer.mjs @@ -1,6 +1,8 @@ // Renderers for the connector namespace picker and connector catalog pages. // Styled to match the reference connector extension UI. +import { CATEGORY } from "./categories.mjs"; + // 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 @@ -51,6 +53,7 @@ export function baseStyles() { --warning: #ca5010; --warning-bg: #fff4ce; --danger: #c50f1f; + --btn-radius: 8px; } @media (prefers-color-scheme: dark) { :root { @@ -85,7 +88,6 @@ body { .header { margin-bottom: 1.25rem; } .head-row { display: flex; align-items: center; justify-content: space-between; gap: 1rem; } h1 { font-size: 1.4rem; font-weight: 600; margin: 0; } -.lede { color: var(--fg); font-size: .9rem; margin: .35rem 0 .15rem; } .sub { color: var(--fg-subtle); font-size: .76rem; } .sub code { font-family: inherit; font-weight: 600; color: var(--fg-muted); } @@ -119,14 +121,32 @@ h1 { font-size: 1.4rem; font-weight: 600; margin: 0; } } .section { margin-top: 1.5rem; } -.section-title { - display: flex; align-items: center; gap: 1rem; - color: var(--fg-muted); font-size: .82rem; font-weight: 600; - text-transform: none; margin: 0 0 .65rem; +.section-head { + display: flex; align-items: center; gap: .6rem; + width: 100%; margin: 0 0 .65rem; padding: .2rem 0; + background: none; border: 0; cursor: pointer; text-align: left; + color: var(--fg-muted); font: inherit; font-size: .82rem; font-weight: 600; +} +.section-head:hover { color: var(--accent); } +.section-head:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; } +.section-head .caret { + width: 12px; height: 12px; flex: none; + color: var(--fg-subtle); transition: transform .15s ease; } -.section-title::before, .section-title::after { - content: ""; flex: 1; height: 1px; background: var(--border); +.section:not(.collapsed) .section-head .caret, +.section.force-open .section-head .caret { transform: rotate(90deg); } +.section-head:hover .caret { color: var(--accent); } +.section-title-text { flex: none; } +.section-count { + flex: none; font-size: .72rem; font-weight: 600; + background: var(--bg-pill); + padding: .05rem .4rem; border-radius: 999px; } +.section-rule { flex: 1; height: 1px; background: var(--border); } +.section.collapsed .grid { display: none; } +/* Search override — must follow the .collapsed rule so it wins on a specificity + tie and expands a matching group without disturbing its stored collapse state. */ +.section.force-open .grid { display: grid; } .grid { display: grid; grid-template-columns: 1fr; gap: .25rem .5rem; } .item { @@ -149,8 +169,10 @@ h1 { font-size: 1.4rem; font-weight: 600; margin: 0; } .item-icon img { width: 32px; height: 32px; object-fit: contain; } .item-body { min-width: 0; } +.item-name-row { display: flex; align-items: center; gap: .4rem; min-width: 0; } .item-name { font-weight: 600; color: var(--fg); + flex: 0 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .item-desc { @@ -160,7 +182,7 @@ h1 { font-size: 1.4rem; font-weight: 600; margin: 0; } } .item-add { - padding: .25rem .65rem; border-radius: 3px; + padding: .25rem .65rem; border-radius: var(--btn-radius); border: 1px solid var(--border-strong); background: var(--bg); color: var(--fg); font-size: .78rem; font-family: inherit; cursor: pointer; @@ -172,12 +194,19 @@ h1 { font-size: 1.4rem; font-weight: 600; margin: 0; } active row promotes its Connect button to filled accent on hover or focus. */ .item-add.primary { background: transparent; border-color: var(--accent); color: var(--accent); } .item:hover .item-add.primary, .item:focus-within .item-add.primary, .item-add.primary:hover, .item-add.primary:focus-visible { background: var(--accent); border-color: var(--accent); color: #fff; } -.item-add.added { color: var(--success); border-color: var(--success-bg); background: var(--success-bg); cursor: default; } -.item-add.added:hover { border-color: var(--success-bg); color: var(--success); } +/* Connected isn't an action — render it as a compact status tag, not a fake + disabled button. Borderless success fill, pill radius, hugs its content. */ +.item-tag { + display: inline-flex; align-items: center; gap: .25rem; + padding: .1rem .4rem; border-radius: 999px; + font-size: .68rem; font-weight: 600; line-height: 1; + white-space: nowrap; user-select: none; flex: none; + color: var(--success); background: var(--success-bg); +} .change-btn { display: inline-flex; align-items: center; gap: 4px; - padding: .3rem .6rem; border-radius: 4px; + padding: .3rem .6rem; border-radius: var(--btn-radius); border: 1px solid var(--border-strong); background: transparent; color: var(--fg-muted); font-size: .75rem; cursor: pointer; font-family: inherit; @@ -251,7 +280,7 @@ button:focus-visible, a:focus-visible, [tabindex]:focus-visible { outline: 2px s // Setup / Namespace Picker // --------------------------------------------------------------------------- -export function renderSetupHtml(subscriptions) { +export function renderSetupHtml(subscriptions, notice = "") { const subOptions = subscriptions.map((s) => `` ).join(""); @@ -279,16 +308,22 @@ export function renderSetupHtml(subscriptions) { display: flex; align-items: center; justify-content: center; gap: .45rem; width: 100%; box-sizing: border-box; appearance: none; font: inherit; font-size: .82rem; font-weight: 600; cursor: pointer; - padding: .55rem .75rem; border-radius: 6px; + padding: .55rem .75rem; border-radius: var(--btn-radius); border: 1px solid var(--accent); background: transparent; color: var(--accent); } .create-link:hover { background: var(--accent); color: #fff; } .create-link .plus { font-size: 1.05rem; line-height: 1; font-weight: 700; } +.setup-notice { + margin: 0 0 1rem; padding: .55rem .7rem; border-radius: 6px; + background: var(--bg-pill); border: 1px solid var(--accent); + color: var(--fg); font-size: .82rem; line-height: 1.5; +}

${brandMark(30, "setup")}Select a Connector Namespace

Choose which connector namespace to browse. This choice is saved for future sessions.
+${notice ? `
${esc(notice)}
` : ""}
@@ -376,7 +411,7 @@ function renderGateways(filter, hasMore) { '
' + escH(gw.resourceGroup) + ' \u2022 ' + escH(gw.location) + '
' ).join(""); if (hasMore) { - html += ''; + html += ''; } gatewayList.innerHTML = html; gatewayList.querySelectorAll(".setup-card").forEach(el => { @@ -433,27 +468,47 @@ async function selectGateway(subscriptionId, resourceGroup, gatewayName) { // --------------------------------------------------------------------------- export function renderCatalogHtml(instanceId, catalog, { filter, category, source, config }) { - const byCategory = new Map(); - for (const c of catalog) { - if (!byCategory.has(c.category)) byCategory.set(c.category, []); - byCategory.get(c.category).push(c); - } - const sortedCats = [...byCategory.keys()].sort(); - - let sectionsHtml = ""; - for (const cat of sortedCats) { - const rows = byCategory.get(cat).sort((a, b) => a.displayName.localeCompare(b.displayName)); - const rowsHtml = rows.map((c) => { - const icon = c.iconUri - ? `
` - : `
${esc(c.displayName.charAt(0))}
`; - // Button state is hydrated client-side from /api/state on load. - const btn = ``; - const haystack = esc((c.displayName + " " + (c.description || "")).toLowerCase()); - return `
${icon}
${esc(c.displayName)}
${esc(c.description)}
${btn}
`; - }).join(""); - sectionsHtml += `
${esc(cat)}
${rowsHtml}
`; - } + 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 = ``; + const haystack = esc((c.displayName + " " + (c.description || "")).toLowerCase()); + return `
${icon}
${esc(c.displayName)}
${esc(c.description)}
${btn}
`; + }; + + 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 `
` + + `` + + `
${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.
`; @@ -462,11 +517,7 @@ export function renderCatalogHtml(instanceId, catalog, { filter, category, sourc return ` Connectors${baseStyles()}