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

* Add MCP Connectors (connector-namespaces) canvas extension

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

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

* Update Connector Namespaces canvas extension

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

* Harden Connector Namespaces canvas extension

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

* Sanitize connector icon brand colors

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

* Add Connector Namespace playground actions

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

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

* Replace sandbox skill with native tool

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

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

* Clarify connector disconnect action

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

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

* Color disconnect action red

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

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

* Add connector extension plugin manifest

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

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

* Address connector canvas review feedback

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

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

* Remove legacy connector canvas manifest

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

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

* Address remaining connector review feedback

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

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

* Harden connector review fixes

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

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

* Harden connector convergence

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

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

* Use native HTTP connector configs

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

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

* Secure persisted connector state

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

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

---------

Co-authored-by: Alex Yang <yangalex@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Alex Yang [MSFT]
2026-07-15 17:29:48 -07:00
committed by GitHub
parent e4a1f57fd9
commit 191309165e
35 changed files with 7658 additions and 0 deletions
@@ -0,0 +1,19 @@
{
"name": "connector-namespaces",
"description": "Browse, connect, and open MCP connectors from an Azure Connector Namespace.",
"version": "1.1.0",
"author": {
"name": "Alex Yang",
"url": "https://github.com/alexyaang"
},
"keywords": [
"azure",
"connector-namespace",
"mcp",
"mcp-connectors",
"model-context-protocol",
"tool-discovery"
],
"logo": "assets/preview.png",
"extensions": "."
}
+21
View File
@@ -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.
+85
View File
@@ -0,0 +1,85 @@
# MCP Connectors — Copilot CLI Canvas Extension
A GitHub Copilot CLI **canvas extension** that lets you browse and add MCP
connectors from an Azure **Connector Namespace** directly inside a Copilot CLI
session. Search by name or category, sign in to a connector, then restart the
session to make its tools available to the agent.
> The canvas talks to public Azure Resource Manager (`management.azure.com`)
> using the signed-in Azure CLI account. The extension does not register its own
> Entra application or persist Azure credentials.
## Prerequisites
- **GitHub Copilot CLI** (the host that loads canvas extensions).
- **Azure CLI**, signed in with `az login`. The extension asks Azure CLI for a
short-lived ARM access token and refreshes it through the same broker.
- **An Azure subscription with a Connector Namespace** — resource type
`Microsoft.Web/connectorGateways` (API version `2026-05-01-preview`). This is
a preview resource provider; you must have access to it for the catalog to
load. Without it the extension installs fine but has nothing to show.
## Install
Install it from the public Awesome Copilot repository:
```
install_extension https://github.com/github/awesome-copilot/tree/main/extensions/connector-namespaces
```
For a reproducible install, swap `main` for a reviewed commit SHA from this
repository.
The destination **scope** is chosen at install time:
- **user** (default) — installs globally for you at
`$COPILOT_HOME/extensions/connector-namespaces/`. The usual choice for a
personal tool.
- **project** — installs into the current repo.
- **session** — scoped to a single CLI session.
## Usage
1. Open the **MCP Connectors** canvas from Copilot CLI.
2. The canvas loads subscriptions from your signed-in Azure CLI account. Pick an
Azure **subscription** and a **Connector Namespace**. The choice is saved for
future sessions (change it any time via **Change namespace**).
3. Browse or filter the connector catalog, then **Connect**. A browser tab
opens for Microsoft sign-in; complete it and the canvas updates on its own.
4. Connected connectors move into **My MCPs**. Use **Sandbox** on a tile to open
that server directly in the namespace MCP playground.
5. Restart the Copilot CLI session so the agent can load the connected tools.
The extension registers the native `connector_namespaces_open_playground` tool,
so GitHub Copilot can open a named connector from **My MCPs** without installing
an additional Agent Skill.
## How it works
- `extension.mjs` — entry point; declares the canvas, `open_sandbox` action, and
native `connector_namespaces_open_playground` tool.
- `server.mjs` — a loopback HTTP server (bound to `127.0.0.1` only) that serves
the canvas UI and the JSON/OAuth endpoints the iframe calls.
- `armClient.mjs` — thin ARM client (token brokered by Azure CLI, public ARM
base only, SSRF-guarded path segments).
- `catalog.mjs` — fetches and curates the connector list for a namespace.
- `install.mjs` — the connect/install pipeline (managed-API connection, consent,
rollback on cancel, and native HTTPS MCP config registration).
- `renderer.mjs` — all canvas HTML/CSS/client JS.
- `sandbox.mjs` — builds namespace playground links and resolves named My MCPs.
- `state.mjs` — saved namespace and connector state.
## Privacy & security
- ARM tokens come from `az account get-access-token`, stay in process memory,
and are never logged or written by the extension. Azure CLI owns sign-in and
credential storage.
- All servers bind to loopback (`127.0.0.1`) and are never exposed externally.
- ARM requests go only to `https://management.azure.com/`; path segments are
validated to prevent SSRF-style host smuggling.
- The minted gateway API key is stored in the selected Copilot MCP config and
sent to its validated HTTPS endpoint as the `X-API-Key` header.
## License
[MIT](./LICENSE) © Microsoft Corporation.
@@ -0,0 +1,444 @@
// ARM API client — fetches real connector data with Azure CLI credentials.
import { exec, execFile } from "node:child_process";
import { constants as fsConstants, promises as fs } from "node:fs";
import { homedir, platform } from "node:os";
import { basename, delimiter, dirname, isAbsolute, join, resolve, sep } from "node:path";
import { promisify } from "node:util";
const API_VERSION = "2026-05-01-preview";
const RG_API_VERSION = "2021-04-01";
const MSI_API_VERSION = "2023-01-31";
const SUBS_API_VERSION = "2020-01-01";
const execFileAsync = promisify(execFile);
const execAsync = promisify(exec);
const ARM_RESOURCE = "https://management.azure.com/";
const EXPIRY_SKEW_MS = 5 * 60 * 1000;
const LEGACY_AUTH_CACHE = join(
process.env.COPILOT_HOME || join(homedir(), ".copilot"),
"extensions",
"connector-namespaces",
"artifacts",
"auth-cache.json",
);
let s_auth = null; // { token, expiresAt }
let s_authInFlight = null;
let s_legacyAuthCacheRemoved = false;
export function parseAzureCliToken(stdout) {
let data;
try {
data = JSON.parse(stdout);
} catch {
throw new Error("Azure CLI returned invalid token JSON.");
}
const token = data?.accessToken;
const epochSeconds = Number(data?.expires_on);
const expiresAt = Number.isFinite(epochSeconds) && epochSeconds > 0
? epochSeconds * 1000
: Date.parse(data?.expiresOn);
if (typeof token !== "string" || token.length === 0 || !Number.isFinite(expiresAt)) {
throw new Error("Azure CLI returned an incomplete ARM token.");
}
return { token, expiresAt };
}
async function removeLegacyAuthCache() {
if (s_legacyAuthCacheRemoved) return;
try {
await fs.unlink(LEGACY_AUTH_CACHE);
} catch (error) {
if (error?.code !== "ENOENT") {
throw new Error(`Could not remove the legacy connector credential cache at ${LEGACY_AUTH_CACHE}: ${error.message}`);
}
}
s_legacyAuthCacheRemoved = true;
}
function windowsSystemExecutable(name) {
const systemRoot = process.env.SystemRoot;
if (systemRoot && isAbsolute(systemRoot)) return join(systemRoot, "System32", name);
throw new Error(`Could not resolve the Windows system executable ${name}.`);
}
async function trustedExecutablePath(path, expectedName, workspaceRoot = process.cwd()) {
if (!isAbsolute(path) || /["\r\n]/.test(path)) return null;
let candidate;
let workspace;
try {
[candidate, workspace] = await Promise.all([
fs.realpath(path),
fs.realpath(workspaceRoot),
]);
if (!(await fs.stat(candidate)).isFile()) return null;
if (platform() !== "win32") await fs.access(candidate, fsConstants.X_OK);
} catch {
return null;
}
const insensitive = platform() === "win32";
const normalize = (value) => insensitive ? value.toLowerCase() : value;
const normalizedCandidate = normalize(resolve(candidate));
const normalizedWorkspace = normalize(resolve(workspace));
const workspacePrefix = normalizedWorkspace.endsWith(sep)
? normalizedWorkspace
: `${normalizedWorkspace}${sep}`;
if (
normalize(basename(candidate)) !== normalize(expectedName) ||
normalizedCandidate === normalizedWorkspace ||
normalizedCandidate.startsWith(workspacePrefix)
) return null;
return candidate;
}
async function resolveWindowsAzureCli() {
const trustedCwd = homedir();
const { stdout } = await execFileAsync(
windowsSystemExecutable("where.exe"),
["az.cmd"],
{ cwd: trustedCwd, encoding: "utf8", windowsHide: true, timeout: 10_000, maxBuffer: 64 * 1024 },
);
for (const path of stdout.split(/\r?\n/).map((line) => line.trim())) {
if (/[%]/.test(path)) continue;
const candidate = await trustedExecutablePath(path, "az.cmd");
if (candidate) return candidate;
}
throw new Error("Azure CLI was not found outside the current workspace.");
}
export async function resolvePosixAzureCli(pathValue = process.env.PATH || "", workspaceRoot = process.cwd()) {
for (const directory of pathValue.split(delimiter)) {
const candidate = await trustedExecutablePath(resolve(directory || workspaceRoot, "az"), "az", workspaceRoot);
if (candidate) return candidate;
}
throw new Error("Azure CLI was not found outside the current workspace.");
}
export async function resolveSystemExecutable(name, workspaceRoot = process.cwd()) {
const candidates = platform() === "win32"
? [windowsSystemExecutable(name)]
: [join("/usr/bin", name), join("/bin", name), join("/usr/local/bin", name)];
for (const path of candidates) {
const candidate = await trustedExecutablePath(path, name, workspaceRoot);
if (candidate) return candidate;
}
throw new Error(`Could not resolve the trusted system executable ${name}.`);
}
async function acquireToken() {
await removeLegacyAuthCache();
try {
const windows = platform() === "win32";
const azureCli = windows ? await resolveWindowsAzureCli() : await resolvePosixAzureCli();
const options = { cwd: homedir(), encoding: "utf8", windowsHide: true, timeout: 60_000, maxBuffer: 1024 * 1024 };
const { stdout } = windows
? await execAsync(
`"${azureCli}" account get-access-token --resource https://management.azure.com/ --output json --only-show-errors`,
{ ...options, shell: windowsSystemExecutable("cmd.exe") },
)
: await execFileAsync(
azureCli,
["account", "get-access-token", "--resource", ARM_RESOURCE, "--output", "json", "--only-show-errors"],
options,
);
s_auth = parseAzureCliToken(stdout);
return s_auth.token;
} catch (error) {
const detail = String(error?.stderr || error?.message || "").trim();
throw new Error(
`Azure CLI authentication failed. Install Azure CLI and run "az login" before opening the canvas.${detail ? ` ${detail}` : ""}`,
);
}
}
export async function getToken() {
if (s_auth && s_auth.expiresAt - EXPIRY_SKEW_MS > Date.now()) return s_auth.token;
if (s_authInFlight) return s_authInFlight;
s_authInFlight = acquireToken().finally(() => {
s_authInFlight = null;
});
return s_authInFlight;
}
/**
* List all enabled Azure subscriptions the user has access to.
*/
// The set of enabled subscriptions is stable for a session, so cache it — the
// first /setup pays the ARM round-trip once and every "Change namespace"
// afterwards serves from memory.
let s_subsCache = null; // { subs, expiresAt }
const SUBS_TTL_MS = 30 * 60 * 1000;
export async function listSubscriptions() {
const now = Date.now();
if (s_subsCache && s_subsCache.expiresAt > now) return s_subsCache.subs;
const token = await getToken();
const url = `https://management.azure.com/subscriptions?api-version=${SUBS_API_VERSION}`;
const raw = await paginateAll(url, token);
const subs = raw
.filter((s) => s.state === "Enabled")
.map((s) => ({ id: s.subscriptionId, name: s.displayName, tenantId: s.tenantId, state: s.state }));
s_subsCache = { subs, expiresAt: now + SUBS_TTL_MS };
return subs;
}
// ARM resource identifiers are a restricted charset (letters, digits and a few
// punctuation chars). Validating each path segment against this allowlist before
// it enters a URL rejects anything containing "/", "?", "#", "@" or ":" — the
// characters that could otherwise alter the request path or redirect the host —
// and acts as a taint barrier so config/file-derived names cannot reach fetch
// unvalidated.
const ARM_SEGMENT = /^[A-Za-z0-9._()-]{1,256}$/;
export function armSegment(value) {
const s = String(value);
if (s === "." || s === ".." || !ARM_SEGMENT.test(s)) {
throw new Error(`Invalid ARM resource identifier: ${s}`);
}
return s;
}
function buildBaseUrl(subscriptionId, resourceGroup, gatewayName) {
return `https://management.azure.com/subscriptions/${armSegment(subscriptionId)}/resourceGroups/${armSegment(resourceGroup)}/providers/Microsoft.Web/connectorGateways/${armSegment(gatewayName)}`;
}
// Hard host allowlist: every request this client makes targets ARM and only
// ARM. The trailing slash matters — it blocks suffix/userinfo bypasses such as
// "https://management.azure.com.evil.com/" and "https://management.azure.com@evil.com/",
// neither of which starts with this exact prefix. This guards the paginated
// nextLink (a server-supplied value) which does not pass through armSegment.
const ARM_BASE = "https://management.azure.com/";
// Returns the URL only if it targets ARM, otherwise throws. Used by callers
// (e.g. install.mjs) that build ARM URLs before handing them here.
export function assertArmHost(rawUrl) {
const url = String(rawUrl);
if (!url.startsWith(ARM_BASE)) {
throw new Error(`Refusing to call non-ARM URL: ${url}`);
}
return url;
}
async function armFetch(url, token) {
// Guard the exact value handed to fetch so a tainted path segment or a
// server-supplied nextLink can never redirect the call off ARM.
if (!url.startsWith(ARM_BASE)) {
throw new Error(`Refusing to call non-ARM URL: ${url}`);
}
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
});
if (!res.ok) {
const body = await res.text();
throw new Error(`ARM ${res.status}: ${body.slice(0, 300)}`);
}
return res.json();
}
// ARM normally returns distinct nextLink URLs that terminate, but a buggy or
// hostile endpoint could return a repeating/self-referential nextLink. Guard
// against an unbounded loop with a seen-set and a hard page cap.
const MAX_PAGES = 1000;
async function paginateAll(url, token) {
const results = [];
const seen = new Set();
let nextUrl = url;
let pages = 0;
while (nextUrl) {
if (seen.has(nextUrl) || pages >= MAX_PAGES) break;
seen.add(nextUrl);
pages++;
const data = await armFetch(nextUrl, token);
if (data.value) results.push(...data.value);
nextUrl = data.nextLink || null;
}
return results;
}
/**
* List connector gateways in a subscription.
* Uses $top=10 and stops after the first page for speed.
* Pass fetchAll=true to paginate through everything.
*/
export async function listConnectorGateways(subscriptionId, { fetchAll = false } = {}) {
const token = await getToken();
const url = `https://management.azure.com/subscriptions/${armSegment(subscriptionId)}/providers/Microsoft.Web/connectorGateways?api-version=${API_VERSION}&$top=10`;
if (fetchAll) return { items: await paginateAll(url, token), hasMore: false };
// First page only — much faster
const data = await armFetch(url, token);
const items = data.value || [];
return { items, hasMore: !!data.nextLink };
}
/**
* List managed APIs (traditional connectors)
*/
export async function listManagedApis(subscriptionId, resourceGroup, gatewayName) {
const token = await getToken();
const url = `${buildBaseUrl(subscriptionId, resourceGroup, gatewayName)}/managedApis?api-version=${API_VERSION}`;
return paginateAll(url, token);
}
/**
* List managed hosted MCP servers
*/
export async function listManagedHostedMcpServers(subscriptionId, resourceGroup, gatewayName) {
const token = await getToken();
const url = `${buildBaseUrl(subscriptionId, resourceGroup, gatewayName)}/managedHostedMcpServers?api-version=${API_VERSION}`;
return paginateAll(url, token);
}
/**
* List managed MCP operations
*/
export async function listManagedMcpOperations(subscriptionId, resourceGroup, gatewayName) {
const token = await getToken();
const url = `${buildBaseUrl(subscriptionId, resourceGroup, gatewayName)}/managedMcpOperations?api-version=${API_VERSION}`;
return paginateAll(url, token);
}
// ---------------------------------------------------------------------------
// Create connector namespace (provisioning flow)
// ---------------------------------------------------------------------------
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Write helper (PUT/PATCH/DELETE) that mirrors armFetch's host guard but keeps
// the parsed error body so callers can surface ARM's message verbatim.
async function armWrite(method, url, body, extraHeaders = {}) {
if (!url.startsWith(ARM_BASE)) {
throw new Error(`Refusing to call non-ARM URL: ${url}`);
}
const token = await getToken();
const headers = { Authorization: `Bearer ${token}` };
Object.assign(headers, extraHeaders);
if (body !== undefined) headers["Content-Type"] = "application/json";
const res = await fetch(url, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let parsed;
try { parsed = text ? JSON.parse(text) : undefined; } catch { parsed = text; }
if (!res.ok) {
const msg = parsed?.error?.message ?? parsed?.message ?? text ?? `HTTP ${res.status}`;
const err = new Error(`ARM ${method} ${res.status}: ${String(msg).slice(0, 300)}`);
err.status = res.status;
throw err;
}
return parsed;
}
/**
* List resource groups in a subscription (sorted by name).
*/
export async function listResourceGroups(subscriptionId) {
const token = await getToken();
const url = `https://management.azure.com/subscriptions/${armSegment(subscriptionId)}/resourcegroups?api-version=${RG_API_VERSION}`;
const items = await paginateAll(url, token);
return items
.map((rg) => ({ name: rg.name, location: rg.location }))
.sort((a, b) => a.name.localeCompare(b.name));
}
/**
* Create a resource group without updating an existing group on a name race.
*/
export async function createResourceGroup(subscriptionId, name, location) {
const url = `https://management.azure.com/subscriptions/${armSegment(subscriptionId)}/resourcegroups/${armSegment(name)}?api-version=${RG_API_VERSION}`;
return armWrite("PUT", url, { location }, { "If-None-Match": "*" });
}
/**
* List user-assigned managed identities across a subscription (sorted by name).
*/
export async function listUserAssignedIdentities(subscriptionId) {
const token = await getToken();
const url = `https://management.azure.com/subscriptions/${armSegment(subscriptionId)}/providers/Microsoft.ManagedIdentity/userAssignedIdentities?api-version=${MSI_API_VERSION}`;
const items = await paginateAll(url, token);
return items
.map((id) => {
const parts = String(id.id).split("/");
const rgIdx = parts.findIndex((p) => p.toLowerCase() === "resourcegroups");
return {
id: id.id,
name: id.name,
resourceGroup: rgIdx >= 0 ? parts[rgIdx + 1] || "" : "",
location: id.location || "",
};
})
.sort((a, b) => a.name.localeCompare(b.name));
}
/**
* Check whether a connector namespace name is free in the given resource group.
* Returns true when available (ARM 404), false when taken (200). Uses fetch
* directly so the 404 isn't thrown the way armFetch would.
*/
export async function checkConnectorGatewayNameAvailable(subscriptionId, resourceGroup, gatewayName) {
const token = await getToken();
const url = `${buildBaseUrl(subscriptionId, resourceGroup, gatewayName)}?api-version=${API_VERSION}`;
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (res.status === 404) return true;
if (res.ok) return false;
const body = await res.text();
throw new Error(`ARM ${res.status}: ${body.slice(0, 200)}`);
}
// ARM `identity` block — mirrors the portal's buildIdentityPayload so the PUT
// body is always explicit ({ type: "None" } when nothing is configured).
export function buildGatewayIdentity(enableSystem, userAssignedIds = []) {
const hasUser = userAssignedIds.length > 0;
const type = enableSystem && hasUser
? "SystemAssigned,UserAssigned"
: enableSystem
? "SystemAssigned"
: hasUser
? "UserAssigned"
: "None";
const identity = { type };
if (hasUser) {
identity.userAssignedIdentities = Object.fromEntries(userAssignedIds.map((id) => [id, {}]));
}
return identity;
}
export async function waitForProvisioning(initialResult, gatewayName, fetchLatest, {
maxPolls = 60,
delay = () => sleep(3000),
} = {}) {
let result = initialResult;
let state;
for (let poll = 0; poll <= maxPolls; poll++) {
state = result?.properties?.provisioningState;
if (state === "Succeeded") return result;
if (state === "Failed" || state === "Canceled") {
throw new Error(`Provisioning ${state} for "${gatewayName}".`);
}
if (poll === maxPolls) break;
await delay();
result = await fetchLatest();
}
throw new Error(`Provisioning timed out for "${gatewayName}" (last state: ${state ?? "unknown"}).`);
}
/**
* Create a connector namespace and poll until the
* provisioningState reaches a terminal value. Throws on Failed/Canceled.
* Returns the final resource object.
*/
export async function createConnectorGateway(subscriptionId, resourceGroup, gatewayName, { location, identity }) {
const token = await getToken();
const url = `${buildBaseUrl(subscriptionId, resourceGroup, gatewayName)}?api-version=${API_VERSION}`;
const body = { location, properties: {}, identity };
const result = await armWrite("PUT", url, body, { "If-None-Match": "*" });
// ~3 min ceiling (60 * 3s). A 202 may have no body, so every state other
// than explicit Succeeded enters the polling path.
return waitForProvisioning(result, gatewayName, () => armFetch(url, token));
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

@@ -0,0 +1,70 @@
// Catalog — fetches MCP connectors from the gateway.
//
// The gateway exposes ~1600 managed APIs (the full Logic Apps connector
// catalog). MCP servers are a small subset (~43) and there is no `kind` or
// capability flag that distinguishes them. The only reliable signal is the
// string "mcp" appearing in the API's name OR its display name — and those are
// genuinely independent signals: `workiqsharepoint` has no "mcp" in its name
// (display name "Work IQ SharePoint MCP"), while `hginsightsmcp` has "mcp" in
// its name but a display name of "HG Insights Connect". Matching either keeps
// the full set without an allowlist that has to be hand-maintained.
import { listManagedApis } from "./armClient.mjs";
import { CATEGORY } from "./categories.mjs";
function isMcpServer(api) {
const name = api.name || "";
const displayName = api.properties?.generalInformation?.displayName || "";
return /mcp/i.test(name) || /mcp/i.test(displayName);
}
// Microsoft first-party servers (a365*/d365*/workiq* names, or a Microsoft-
// branded display name) group under "Microsoft"; everything else is a partner
// server. Derived rather than hardcoded so new servers categorize themselves.
function categoryFor(name, displayName) {
const n = (name || "").toLowerCase();
const d = (displayName || "").toLowerCase();
const isMicrosoft =
/^(a365|d365|workiq)/.test(n) ||
d.startsWith("microsoft") ||
d.startsWith("work iq") ||
d.startsWith("dynamics 365");
return isMicrosoft ? CATEGORY.microsoft : CATEGORY.partner;
}
let cachedCatalog = null;
let cacheKey = null;
export function invalidateCache() {
cachedCatalog = null;
cacheKey = null;
}
export async function fetchCatalog(subscriptionId, resourceGroup, gatewayName) {
const key = `${subscriptionId}/${resourceGroup}/${gatewayName}`;
if (cachedCatalog && cacheKey === key) return cachedCatalog;
const apis = await listManagedApis(subscriptionId, resourceGroup, gatewayName);
const catalog = apis
.filter(isMcpServer)
.map((a) => {
const props = a.properties || {};
const general = props.generalInformation || {};
const metadata = props.metadata || {};
const displayName = general.displayName || a.name;
return {
id: a.name,
apiName: a.name,
displayName,
description: general.description || "",
iconUri: general.iconUri || "",
brandColor: metadata.brandColor || "",
category: categoryFor(a.name, displayName),
};
});
cachedCatalog = catalog;
cacheKey = key;
return catalog;
}
@@ -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",
});
@@ -0,0 +1,4 @@
{
"name": "connector-namespaces",
"version": 1
}
@@ -0,0 +1,403 @@
// Renderer for the "Create connector namespace" wizard page. Mirrors the
// portal's CreateConnectorGatewayPage: subscription -> resource group
// (existing or new) -> region -> name (live availability) -> managed identity
// (system + user-assigned) -> real ARM provisioning.
import { baseStyles, brandMark } from "./renderer.mjs";
function esc(s) {
return String(s || "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
// Connector namespace regions — kept in sync with the portal's
// CONNECTOR_NAMESPACE_REGIONS list (constants.ts).
const REGIONS = [
["australiaeast", "Australia East"], ["brazilsouth", "Brazil South"],
["canadacentral", "Canada Central"], ["canadaeast", "Canada East"],
["centralindia", "Central India"], ["centralus", "Central US"],
["eastasia", "East Asia"], ["eastus", "East US"], ["eastus2", "East US 2"],
["francecentral", "France Central"], ["germanywestcentral", "Germany West Central"],
["italynorth", "Italy North"], ["japaneast", "Japan East"],
["koreacentral", "Korea Central"], ["northcentralus", "North Central US"],
["northeurope", "North Europe"], ["norwayeast", "Norway East"],
["polandcentral", "Poland Central"], ["southafricanorth", "South Africa North"],
["southcentralus", "South Central US"], ["southindia", "South India"],
["southeastasia", "Southeast Asia"], ["spaincentral", "Spain Central"],
["swedencentral", "Sweden Central"], ["switzerlandnorth", "Switzerland North"],
["uaenorth", "UAE North"], ["uksouth", "UK South"],
["westcentralus", "West Central US"], ["westus2", "West US 2"],
["westus3", "West US 3"],
];
const DEFAULT_REGION = "eastus";
export function renderCreateNamespaceHtml(subscriptions, preselectedSub = "", capabilityToken = "") {
const subOptions = subscriptions.map((s) =>
`<option value="${esc(s.id)}"${s.id === preselectedSub ? " selected" : ""}>${esc(s.name)} (${esc(s.id.slice(0, 8))}\u2026)</option>`
).join("");
const regionOptions = REGIONS.map(([v, l]) =>
`<option value="${v}"${v === DEFAULT_REGION ? " selected" : ""}>${l}</option>`
).join("");
return `<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Create Connector Namespace</title>${baseStyles()}
<style>
.crt-back { display:inline-flex; align-items:center; gap:.35rem; background:none; border:0; color:var(--fg-muted); font:inherit; font-size:.8rem; cursor:pointer; padding:0; margin-bottom:.65rem; }
.crt-back:hover { color:var(--accent); }
.field { margin-bottom: 1rem; }
.field > label { display:block; font-size:.8rem; font-weight:600; margin-bottom:.3rem; }
.field .hint { font-size:.72rem; color:var(--fg-muted); margin-top:.25rem; }
.crt-input { width:100%; padding:.45rem .65rem; border-radius:4px; border:1px solid var(--border-strong); background:var(--bg); color:var(--fg); font-size:.85rem; font-family:inherit; box-sizing:border-box; }
.crt-input:focus { outline:none; border-color:var(--accent); box-shadow:0 0 0 1px var(--accent); }
.seg { display:inline-flex; border:1px solid var(--border-strong); border-radius:6px; overflow:hidden; margin-bottom:.4rem; }
.seg button { appearance:none; border:0; background:var(--bg); color:var(--fg-muted); font:inherit; font-size:.78rem; padding:.32rem .7rem; cursor:pointer; }
.seg button.active { background:var(--accent); color:#fff; }
.name-status { font-size:.74rem; margin-top:.3rem; min-height:1rem; }
.name-status.ok { color:var(--accent); }
.name-status.bad { color:var(--danger); }
.name-status.checking { color:var(--fg-muted); }
.idn-row { display:flex; align-items:center; gap:.5rem; padding:.35rem .15rem; }
.idn-row label { font-size:.82rem; }
.uami-box { border:1px solid var(--border); border-radius:6px; padding:.4rem .55rem; max-height:160px; overflow-y:auto; }
.uami-item { display:flex; align-items:center; gap:.55rem; padding:.3rem .15rem; font-size:.8rem; }
.uami-item .meta { color:var(--fg-muted); font-size:.72rem; }
.uami-empty { font-size:.78rem; color:var(--fg-muted); padding:.3rem .15rem; }
.crt-actions { display:flex; gap:.6rem; justify-content:flex-end; align-items:center; margin-top:1.4rem; }
.btn { appearance:none; font:inherit; font-size:.83rem; padding:.5rem 1rem; border-radius:6px; cursor:pointer; border:1px solid var(--border-strong); background:var(--bg); color:var(--fg); }
.btn:hover { border-color:var(--accent); }
.btn.primary { background:var(--accent); border-color:var(--accent); color:#fff; }
.btn.primary:hover { background:var(--accent-hover); border-color:var(--accent-hover); }
.btn:disabled { opacity:.5; cursor:not-allowed; }
.btn.primary:disabled:hover { background:var(--accent); border-color:var(--accent); }
.progress { display:none; margin-top:1rem; padding:.7rem .85rem; border-radius:6px; border:1px solid var(--border); background:var(--bg-hover); font-size:.82rem; }
.progress .spin { display:inline-block; width:14px; height:14px; border:2px solid var(--bg-pill); border-top-color:var(--accent); border-radius:50%; animation:spin .8s linear infinite; vertical-align:-2px; margin-right:.5rem; }
.progress.error { border-color:var(--danger); color:var(--danger); }
.progress.success { border-color:var(--accent); }
@keyframes spin { 0% { transform:rotate(0deg); } 100% { transform:rotate(360deg); } }
</style></head><body>
<button class="crt-back" id="back-btn" type="button">\u2190 Back to namespaces</button>
<div class="header brand-head">
<h1>${brandMark(28, "create")}<span>Create connector namespace</span></h1>
<div class="sub">Provisions a real Azure connector namespace (Microsoft.Web/connectorGateways) in your subscription.</div>
</div>
<div class="field">
<label for="sub-select">Subscription</label>
<select id="sub-select" class="crt-input">
<option value="">-- Select subscription --</option>
${subOptions}
</select>
</div>
<div class="field">
<label>Resource group</label>
<div class="seg" id="rg-mode">
<button type="button" data-mode="existing" class="active">Use existing</button>
<button type="button" data-mode="new">Create new</button>
</div>
<select id="rg-select" class="crt-input"><option value="">-- Select subscription first --</option></select>
<input id="rg-new" class="crt-input" type="text" placeholder="New resource group name" autocomplete="off" spellcheck="false" style="display:none;">
<div class="hint" id="rg-hint">Pick the resource group the namespace will live in.</div>
</div>
<div class="field">
<label for="region-select">Region</label>
<select id="region-select" class="crt-input">${regionOptions}</select>
</div>
<div class="field">
<label for="name-input">Name</label>
<input id="name-input" class="crt-input" type="text" placeholder="my-connector-namespace" autocomplete="off" spellcheck="false">
<div class="name-status" id="name-status"></div>
</div>
<div class="field">
<label>Managed identity</label>
<div class="idn-row">
<input type="checkbox" id="sys-identity">
<label for="sys-identity">System-assigned</label>
</div>
<div class="idn-row">
<input type="checkbox" id="uami-toggle">
<label for="uami-toggle">User-assigned</label>
</div>
<div class="uami-box" id="uami-box" style="display:none;"><div class="uami-empty">Select a subscription to list identities.</div></div>
</div>
<div class="crt-actions">
<button class="btn" id="cancel-btn" type="button">Cancel</button>
<button class="btn primary" id="create-btn" type="button" disabled>Create</button>
</div>
<div class="progress" id="progress"></div>
<script>
const connectorNamespaceToken = ${JSON.stringify(capabilityToken)};
const rawFetch = window.fetch.bind(window);
window.fetch = (input, init = {}) => {
const url = typeof input === "string" ? input : input && input.url;
if (url && (url.startsWith("/api/") || url.startsWith("/oauth-status"))) {
const next = { ...init };
const headers = new Headers(next.headers || (typeof input !== "string" ? input.headers : undefined));
headers.set("x-connector-namespace-token", connectorNamespaceToken);
next.headers = headers;
return rawFetch(input, next);
}
return rawFetch(input, init);
};
const subSelect = document.getElementById("sub-select");
const rgModeWrap = document.getElementById("rg-mode");
const rgSelect = document.getElementById("rg-select");
const rgNew = document.getElementById("rg-new");
const rgHint = document.getElementById("rg-hint");
const regionSelect = document.getElementById("region-select");
const nameInput = document.getElementById("name-input");
const nameStatus = document.getElementById("name-status");
const sysIdentity = document.getElementById("sys-identity");
const uamiBox = document.getElementById("uami-box");
const uamiToggle = document.getElementById("uami-toggle");
const createBtn = document.getElementById("create-btn");
const backBtn = document.getElementById("back-btn");
const cancelBtn = document.getElementById("cancel-btn");
const progress = document.getElementById("progress");
let rgMode = "existing";
let nameCheck = "idle"; // idle | checking | available | taken | error
let creating = false;
let nameTimer = null;
let checkSeq = 0;
let resourceGroupsSeq = 0;
let identitiesSeq = 0;
backBtn.onclick = () => { if (!creating) window.location.href = "/setup"; };
cancelBtn.onclick = () => { if (!creating) window.location.href = "/setup"; };
function setFormLocked(locked) {
for (const control of document.querySelectorAll("button, input, select")) {
if (locked) {
if (control.dataset.preCreateDisabled === undefined) {
control.dataset.preCreateDisabled = control.disabled ? "1" : "0";
}
control.disabled = true;
} else {
control.disabled = control.dataset.preCreateDisabled === "1";
delete control.dataset.preCreateDisabled;
}
}
document.body.setAttribute("aria-busy", locked ? "true" : "false");
}
function escH(s) { return String(s||"").replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c])); }
function effectiveRg() {
return rgMode === "new" ? rgNew.value.trim() : rgSelect.value;
}
function nameError() {
const v = nameInput.value.trim();
if (!v) return "Name is required";
if (!/^[a-zA-Z0-9][a-zA-Z0-9-]*$/.test(v)) return "Start with a letter/number; letters, numbers and hyphens only";
if (v.length > 64) return "Max 64 characters";
return "";
}
function refreshButton() {
const ok = !!subSelect.value && !!effectiveRg() && !!regionSelect.value &&
!nameError() && nameCheck === "available" && !creating;
createBtn.disabled = !ok;
}
// --- Resource group mode toggle ---
rgModeWrap.querySelectorAll("button").forEach((b) => {
b.onclick = () => {
rgMode = b.dataset.mode;
rgModeWrap.querySelectorAll("button").forEach((x) => x.classList.toggle("active", x === b));
rgSelect.style.display = rgMode === "existing" ? "block" : "none";
rgNew.style.display = rgMode === "new" ? "block" : "none";
rgHint.textContent = rgMode === "new"
? "A new resource group is created in the selected region."
: "Pick the resource group the namespace will live in.";
scheduleNameCheck();
refreshButton();
};
});
uamiToggle.addEventListener("change", () => {
if (uamiToggle.checked) {
uamiBox.style.display = "";
loadIdentities();
} else {
uamiBox.style.display = "none";
}
});
// --- Subscription change: load resource groups + identities ---
subSelect.addEventListener("change", () => {
loadResourceGroups();
if (uamiToggle.checked) loadIdentities();
scheduleNameCheck();
refreshButton();
});
async function loadResourceGroups() {
const seq = ++resourceGroupsSeq;
const sub = subSelect.value;
if (!sub) { rgSelect.innerHTML = '<option value="">-- Select subscription first --</option>'; return; }
rgSelect.innerHTML = '<option value="">Loading\u2026</option>';
try {
const res = await fetch("/api/resource-groups?subscriptionId=" + encodeURIComponent(sub));
const data = await res.json();
if (seq !== resourceGroupsSeq || sub !== subSelect.value) return;
if (data.error) { rgSelect.innerHTML = '<option value="">Error loading groups</option>'; return; }
const opts = (data.resourceGroups || []).map((g) =>
'<option value="' + escH(g.name) + '">' + escH(g.name) + ' (' + escH(g.location) + ')</option>'
).join("");
rgSelect.innerHTML = '<option value="">-- Select resource group --</option>' + opts;
} catch (e) {
if (seq !== resourceGroupsSeq) return;
rgSelect.innerHTML = '<option value="">Error loading groups</option>';
}
}
async function loadIdentities() {
const seq = ++identitiesSeq;
const sub = subSelect.value;
if (!sub) { uamiBox.innerHTML = '<div class="uami-empty">Select a subscription to list identities.</div>'; return; }
uamiBox.innerHTML = '<div class="uami-empty">Loading identities\u2026</div>';
try {
const res = await fetch("/api/identities?subscriptionId=" + encodeURIComponent(sub));
const data = await res.json();
if (seq !== identitiesSeq || sub !== subSelect.value) return;
if (data.error) { uamiBox.innerHTML = '<div class="uami-empty">Error loading identities</div>'; return; }
const ids = data.identities || [];
if (!ids.length) { uamiBox.innerHTML = '<div class="uami-empty">No user-assigned identities in this subscription.</div>'; return; }
uamiBox.innerHTML = ids.map((id, index) =>
'<div class="uami-item"><input type="checkbox" class="uami-cb" value="' + escH(id.id) + '" id="uami-' + index + '">' +
'<label for="uami-' + index + '">' + escH(id.name) +
' <span class="meta">' + escH(id.resourceGroup) + ' \u2022 ' + escH(id.location) + '</span></label></div>'
).join("");
} catch (e) {
if (seq !== identitiesSeq) return;
uamiBox.innerHTML = '<div class="uami-empty">Error loading identities</div>';
} finally {
if (creating) setFormLocked(true);
}
}
function selectedUserAssignedIds() {
if (!uamiToggle.checked) return [];
return [...uamiBox.querySelectorAll(".uami-cb:checked")].map((c) => c.value);
}
// --- Name availability check (debounced) ---
nameInput.addEventListener("input", () => { scheduleNameCheck(); });
rgSelect.addEventListener("change", () => { scheduleNameCheck(); refreshButton(); });
rgNew.addEventListener("input", () => { scheduleNameCheck(); refreshButton(); });
regionSelect.addEventListener("change", refreshButton);
function scheduleNameCheck() {
clearTimeout(nameTimer);
checkSeq++;
const err = nameError();
if (err) {
nameCheck = "idle";
nameStatus.className = "name-status bad";
nameStatus.textContent = nameInput.value.trim() ? err : "";
refreshButton();
return;
}
if (!subSelect.value || !effectiveRg()) {
nameCheck = "idle";
nameStatus.className = "name-status";
nameStatus.textContent = "Select a subscription and resource group to check availability.";
refreshButton();
return;
}
nameCheck = "checking";
nameStatus.className = "name-status checking";
nameStatus.textContent = "Checking availability\u2026";
refreshButton();
nameTimer = setTimeout(runNameCheck, 450);
}
async function runNameCheck() {
const seq = checkSeq;
const sub = subSelect.value, rg = effectiveRg(), name = nameInput.value.trim();
try {
const res = await fetch("/api/check-name?subscriptionId=" + encodeURIComponent(sub) +
"&resourceGroup=" + encodeURIComponent(rg) + "&name=" + encodeURIComponent(name));
const data = await res.json();
if (seq !== checkSeq) return; // stale
if (data.error) {
nameCheck = "error";
nameStatus.className = "name-status bad";
nameStatus.textContent = "Could not check availability";
} else if (data.available) {
nameCheck = "available";
nameStatus.className = "name-status ok";
nameStatus.textContent = "\u2713 Available";
} else {
nameCheck = "taken";
nameStatus.className = "name-status bad";
nameStatus.textContent = "\u2717 Name already in use in this resource group";
}
} catch (e) {
if (seq !== checkSeq) return;
nameCheck = "error";
nameStatus.className = "name-status bad";
nameStatus.textContent = "Could not check availability";
}
refreshButton();
}
// --- Create ---
createBtn.onclick = async () => {
if (createBtn.disabled) return;
const request = {
subscriptionId: subSelect.value,
resourceGroup: effectiveRg(),
createNewResourceGroup: rgMode === "new",
region: regionSelect.value,
name: nameInput.value.trim(),
enableSystemIdentity: sysIdentity.checked,
userAssignedIds: selectedUserAssignedIds(),
};
creating = true;
setFormLocked(true);
createBtn.textContent = "Creating\u2026";
progress.className = "progress";
progress.style.display = "block";
progress.innerHTML = '<span class="spin"></span>' + (rgMode === "new" ? "Creating resource group and namespace\u2026" : "Creating connector namespace\u2026");
try {
const res = await fetch("/api/create-namespace", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
});
const data = await res.json();
if (data.ok) {
progress.className = "progress success";
progress.textContent = "\u2713 Created \u201c" + request.name + "\u201d. Opening\u2026";
window.location.href = "/";
return;
}
progress.className = "progress error";
progress.textContent = data.error || "Failed to create connector namespace.";
} catch (e) {
progress.className = "progress error";
progress.textContent = "Failed to create connector namespace.";
}
creating = false;
setFormLocked(false);
createBtn.textContent = "Create";
refreshButton();
};
// --- Init (subscription may be preselected from the setup page) ---
if (subSelect.value) { loadResourceGroups(); if (uamiToggle.checked) loadIdentities(); }
scheduleNameCheck();
</script></body></html>`;
}
@@ -0,0 +1,101 @@
// Canvas extension entry point — MCP Connectors browser.
import { joinSession, createCanvas } from "@github/copilot-sdk/extension";
import { getServerConfig, startServer, stopServer } from "./server.mjs";
import { getSavedConfig, loadSavedConfig, saveConfig } from "./state.mjs";
import { fetchCatalog } from "./catalog.mjs";
import { getInstalledState, openInBrowser, setWorkspaceRoot } from "./install.mjs";
import { buildSandboxUrl, resolveSandboxConnector } from "./sandbox.mjs";
// Load any previously saved connector namespace config on startup
loadSavedConfig();
async function openPlayground(server, instanceId) {
const config = instanceId ? getServerConfig(instanceId) : getSavedConfig();
if (!config) return { opened: false, reason: "no_namespace_configured" };
const catalog = await fetchCatalog(config.subscriptionId, config.resourceGroup, config.gatewayName);
const installedState = await getInstalledState(config);
const resolved = resolveSandboxConnector(catalog, installedState, server);
if (!resolved.connector) return { opened: false, ...resolved };
const url = buildSandboxUrl(config, resolved.connector.id);
await openInBrowser(url);
return { opened: true, server: resolved.connector, url };
}
const session = await joinSession({
tools: [
{
name: "connector_namespaces_open_playground",
description: "Open a named connector from My MCPs in the Azure Connector Namespace playground.",
parameters: {
type: "object",
properties: {
server: {
type: "string",
description: "Connector display name or server ID from My MCPs",
},
},
required: ["server"],
},
handler: async ({ server }) => JSON.stringify(await openPlayground(server)),
},
],
canvases: [
createCanvas({
id: "connector-namespaces",
displayName: "MCP Connectors",
description: "Browse, connect, and open MCP connectors in the Azure Connector Namespace Sandbox.",
inputSchema: {
type: "object",
properties: {
subscriptionId: { type: "string", description: "Azure subscription ID (optional \u2014 if omitted, uses saved config or shows picker)" },
resourceGroup: { type: "string", description: "Resource group name" },
gatewayName: { type: "string", description: "Connector namespace name" },
},
},
actions: [
{
name: "open_sandbox",
description: "Open a named connector from My MCPs in the Azure Connector Namespace Sandbox",
inputSchema: {
type: "object",
properties: {
server: {
type: "string",
description: "Connector display name or server ID from My MCPs",
},
},
required: ["server"],
},
handler: async (ctx) => openPlayground(ctx.input.server, ctx.instanceId),
},
],
open: async (ctx) => {
let config;
// If explicit input provided, use it and save for future
if (ctx.input && ctx.input.subscriptionId && ctx.input.resourceGroup && ctx.input.gatewayName) {
config = {
subscriptionId: ctx.input.subscriptionId,
resourceGroup: ctx.input.resourceGroup,
gatewayName: ctx.input.gatewayName,
};
saveConfig(config);
}
// A saved config seeds a new panel only. Rehydrating an existing
// panel keeps its active namespace even if another panel changed
// the persisted default.
const entry = await startServer(
ctx.instanceId,
config ? { config } : { defaultConfig: getSavedConfig() },
);
return { title: "MCP Connectors", url: entry.url };
},
onClose: async (ctx) => {
await stopServer(ctx.instanceId);
},
}),
],
});
// Tell the install pipeline where the workspace .mcp.json lives (if any).
setWorkspaceRoot(session.workspacePath);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,558 @@
// Phase 2 regression: Re-authenticate must re-consent the EXISTING connection and
// mint NO new resources.
//
// Before the fix, the "Re-authenticate" button ran the full install path, so it
// created a fresh connection + a fresh mcpserverConfig on every click. A teammate
// saw a new Dynamics config appear on the namespace each time they re-authed, while
// the panel stayed stuck on "Re-authenticate". This test stubs ARM and proves
// reauthConnector adopts the local session's connection and issues ZERO PUTs.
//
// Run: node --test extensions/connector-namespaces/install.reauth.test.mjs
import { test, after } from "node:test";
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
import { delimiter, join } from "node:path";
import { tmpdir } from "node:os";
// Isolate COPILOT_HOME before importing install.mjs because its paths are bound at
// module-eval time. Put a fake Azure CLI on PATH so getToken() stays offline, and
// seed a profile config so the local entry reads as inCli.
const TMP = mkdtempSync(join(tmpdir(), "cn-reauth-"));
process.env.COPILOT_HOME = TMP;
process.env.USERPROFILE = TMP; // homedir() on Windows
process.env.HOME = TMP; // homedir() on posix
const binDir = join(TMP, "bin");
mkdirSync(binDir, { recursive: true });
const tokenJson = JSON.stringify({ accessToken: "fake-token", expires_on: Math.floor(Date.now() / 1000) + 3600 });
writeFileSync(join(binDir, "az"), `#!/usr/bin/env node\nprocess.stdout.write(${JSON.stringify(tokenJson)});\n`);
chmodSync(join(binDir, "az"), 0o755);
writeFileSync(join(binDir, "az.cmd"), `@echo ${tokenJson}\r\n`);
process.env.PATH = `${binDir}${delimiter}${process.env.PATH || ""}`;
const legacyAuthCache = join(TMP, "extensions", "connector-namespaces", "artifacts", "auth-cache.json");
mkdirSync(join(TMP, "extensions", "connector-namespaces", "artifacts"), { recursive: true });
writeFileSync(legacyAuthCache, JSON.stringify({ accessToken: "legacy", refreshToken: "legacy" }));
writeFileSync(
join(TMP, "mcp-config.json"),
JSON.stringify({ mcpServers: { "docusign-bbb": { type: "http", url: "https://example/mcp" } } }),
);
// Dynamic import AFTER the env is set. A static top-level import would be hoisted
// and evaluate install.mjs (binding the paths to the real home) before the env
// assignments run.
const {
deleteConnection,
finishInstall,
getInstalledState,
installConnector,
loadConnectorMeta,
reauthConnector,
removeMcpEntry,
uninstallConnector,
} = await import("./install.mjs");
after(() => {
try {
rmSync(TMP, { recursive: true, force: true });
} catch {
/* best-effort temp cleanup */
}
});
test("re-authenticate re-consents the existing connection and mints no new resources", async (t) => {
const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "gw1" };
// Two configs for one apiName — the bug scenario. configA is a portal-added
// sibling that is NOT in the local CLI; configB is the one the local session
// points at. Both connections are Connected, so selection turns on inCli:
// deriveInstalledState must pick configB, and the re-consent must target conn-b.
const configA = { name: "docusign-aaa", properties: { connectors: [{ name: "docusign", connectionName: "conn-a" }] } };
const configB = { name: "docusign-bbb", properties: { connectors: [{ name: "docusign", connectionName: "conn-b" }] } };
const connA = { name: "conn-a", properties: { statuses: [{ status: "Connected" }] } };
const connB = { name: "conn-b", properties: { statuses: [{ status: "Connected" }] } };
const calls = [];
const realFetch = globalThis.fetch;
globalThis.fetch = async (urlArg, opts = {}) => {
const url = String(urlArg);
const method = (opts.method || "GET").toUpperCase();
calls.push({ method, url });
const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) });
if (method === "POST" && url.includes("/listConsentLinks")) return ok({ value: [{ link: "https://consent.example/redir" }] });
if (url.includes("/managedApis/") && !url.includes("export=true")) {
return ok({ properties: { connectionParameters: { token: { type: "oauthSetting" } } } });
}
if (method === "GET" && /\/mcpserverConfigs\?/.test(url)) return ok({ value: [configA, configB] });
if (method === "GET" && /\/connections\?/.test(url)) return ok({ value: [connA, connB] });
if (method === "GET" && /\/connectorGateways\/[^/?]+\?/.test(url)) return ok({ location: "eastus" });
throw new Error(`unexpected ARM call: ${method} ${url}`);
};
t.after(() => {
globalThis.fetch = realFetch;
});
const result = await reauthConnector(config, "docusign", "DocuSign", "https://cb/?c=");
assert.equal(existsSync(legacyAuthCache), false, "the legacy refresh-token cache must be removed without reading it");
// Adopts the existing connection, stops at consent, carries the selected config
// through so finish never mints a new one.
assert.equal(result.needsConsent, true);
assert.equal(result.reauth, true);
assert.equal(result.freshConnection, false);
assert.equal(result.connName, "conn-b"); // the inCli config's connection
assert.equal(result.configName, "docusign-bbb"); // never a fresh generateName()
// The core guarantee: nothing was minted. createConnection and
// createMcpServerConfig are the only PUTs on the install path; re-auth issues none.
const puts = calls.filter((c) => c.method === "PUT");
assert.deepEqual(puts, [], `expected zero PUTs, saw: ${puts.map((p) => p.url).join(", ")}`);
// And it re-consented the SELECTED connection, not the portal sibling.
const consent = calls.find((c) => c.url.includes("/listConsentLinks"));
assert.ok(consent && consent.url.includes("/connections/conn-b/"), "consent must target conn-b");
assert.ok(
!calls.some((c) => c.url.includes("/connections/conn-a/listConsentLinks")),
"must not touch the sibling connection conn-a",
);
assert.ok(!calls.some((c) => c.url.includes("export=true")), "reauth must not request unused swagger");
});
test("missing selected connection re-evaluates a valid duplicate before installing", async (t) => {
const configPath = join(TMP, "mcp-config.json");
writeFileSync(
configPath,
JSON.stringify({ mcpServers: { "api-dead": { type: "http", url: "https://example.com/mcp" } } }),
);
const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "gw1" };
const dead = { name: "api-dead", properties: { connectors: [{ name: "shared-api", connectionName: "conn-dead" }] } };
const live = { name: "api-live", properties: { connectors: [{ name: "shared-api", connectionName: "conn-live" }] } };
const calls = [];
const realFetch = globalThis.fetch;
globalThis.fetch = async (urlArg, opts = {}) => {
const url = String(urlArg);
const method = (opts.method || "GET").toUpperCase();
calls.push({ method, url });
const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) });
if (method === "GET" && /\/mcpserverConfigs\?/.test(url)) return ok({ value: [dead, live] });
if (method === "GET" && /\/connections\?/.test(url)) {
return ok({ value: [
{ name: "conn-dead", properties: { statuses: [{ status: "Unknown" }] } },
{ name: "conn-live", properties: { statuses: [{ status: "Connected" }] } },
] });
}
if (method === "GET" && /\/connectorGateways\/[^/?]+\?/.test(url)) return ok({ location: "eastus" });
if (method === "GET" && url.includes("/managedApis/shared-api")) {
return ok({ properties: { connectionParameters: { token: { type: "oauthSetting" } } } });
}
if (method === "POST" && url.includes("/connections/conn-dead/listConsentLinks")) {
return { ok: false, status: 404, text: async () => "gone" };
}
if (method === "POST" && url.includes("/connections/conn-live/listConsentLinks")) {
return ok({ value: [{ link: "https://consent.example/live" }] });
}
if (method === "DELETE" && url.includes("/mcpserverConfigs/api-dead")) return ok({});
if (method === "GET" && url.includes("/mcpserverConfigs/api-dead")) {
return { ok: false, status: 404, text: async () => "gone" };
}
if (method === "DELETE" && url.includes("/connections/conn-dead")) return ok({});
throw new Error(`unexpected ARM call: ${method} ${url}`);
};
t.after(() => {
globalThis.fetch = realFetch;
writeFileSync(configPath, JSON.stringify({ mcpServers: {} }));
});
const result = await reauthConnector(config, "shared-api", "Shared API", "https://cb/?c=");
assert.equal(result.needsConsent, true);
assert.equal(result.configName, "api-live");
assert.equal(result.connName, "conn-live");
assert.ok(calls.some((call) => call.url.includes("/connections/conn-dead/listConsentLinks")));
assert.ok(calls.some((call) => call.url.includes("/connections/conn-live/listConsentLinks")));
assert.equal(calls.some((call) => call.method === "PUT"), false, "valid siblings must prevent a fresh install");
});
test("cross-process MCP config writes preserve every entry", async () => {
const configPath = join(TMP, "mcp-config.json");
writeFileSync(configPath, JSON.stringify({ mcpServers: {} }));
const installUrl = new URL("./install.mjs", import.meta.url).href;
const names = Array.from({ length: 8 }, (_, index) => `parallel-${index}`);
const runWriter = (name) => new Promise((resolve, reject) => {
const metadata = {
gatewayId: "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Web/connectorGateways/gateway",
mcpServerConfigId: `/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Web/connectorGateways/gateway/mcpserverConfigs/${name}`,
connectionId: `/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Web/connectorGateways/gateway/connections/${name}`,
apiName: name,
};
const script = [
`import { writeMcpEntry } from ${JSON.stringify(installUrl)};`,
`await writeMcpEntry(${JSON.stringify(name)}, ${JSON.stringify(`https://example.com/${name}`)}, ${JSON.stringify(`key-${name}`)}, "profile", ${JSON.stringify(metadata)});`,
].join("\n");
const child = spawn(process.execPath, ["--input-type=module", "--eval", script], {
env: { ...process.env, COPILOT_HOME: TMP, HOME: TMP, USERPROFILE: TMP },
stdio: ["ignore", "ignore", "pipe"],
});
let stderr = "";
child.stderr.on("data", (chunk) => { stderr += chunk; });
child.once("error", reject);
child.once("exit", (code) => {
if (code === 0) resolve();
else reject(new Error(`config writer exited ${code}: ${stderr}`));
});
});
await Promise.all(names.map(runWriter));
const stored = JSON.parse(readFileSync(configPath, "utf8")).mcpServers;
assert.deepEqual(Object.keys(stored).sort(), [...names].sort());
for (const name of names) {
assert.equal(stored[name].url, `https://example.com/${name}`);
assert.equal(stored[name].headers["X-API-Key"], `key-${name}`);
assert.deepEqual(Object.keys(stored[name]).sort(), ["_connectorNamespace", "headers", "url"]);
assert.equal(stored[name]._connectorNamespace.apiName, name);
assert.match(stored[name]._connectorNamespace.gatewayId, /connectorGateways\/gateway$/);
}
assert.equal(existsSync(`${configPath}.lock`), false);
await Promise.all(names.map((name) => removeMcpEntry(name)));
assert.deepEqual(JSON.parse(readFileSync(configPath, "utf8")).mcpServers, {});
});
test("connector metadata failures are evicted and retried", async (t) => {
const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "gw1" };
const realFetch = globalThis.fetch;
let calls = 0;
globalThis.fetch = async (urlArg) => {
const url = String(urlArg);
assert.ok(!url.includes("export=true"), "swagger must not be requested when it is not required");
calls++;
if (calls === 1) return { ok: false, status: 400, text: async () => "temporary metadata failure" };
return {
ok: true,
status: 200,
text: async () => JSON.stringify({ properties: { connectionParameters: {} } }),
};
};
t.after(() => {
globalThis.fetch = realFetch;
});
await assert.rejects(loadConnectorMeta(config, "retry-meta", "eastus", false), /metadata failure/);
const meta = await loadConnectorMeta(config, "retry-meta", "eastus", false);
assert.equal(calls, 2);
assert.deepEqual(meta.connectionParameters, {});
});
test("uninstall surfaces connection deletion failures", async (t) => {
writeFileSync(
join(TMP, "mcp-config.json"),
JSON.stringify({ mcpServers: { "docusign-bbb": { type: "http", url: "https://example/mcp" } } }),
);
const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "gw1" };
const remoteConfig = { name: "docusign-bbb", properties: { connectors: [{ name: "docusign", connectionName: "conn-b" }] } };
const connection = { name: "conn-b", properties: { statuses: [{ status: "Connected" }] } };
const realFetch = globalThis.fetch;
const operations = [];
globalThis.fetch = async (urlArg, opts = {}) => {
const url = String(urlArg);
const method = (opts.method || "GET").toUpperCase();
operations.push(`${method} ${url}`);
const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) });
if (method === "GET" && /\/mcpserverConfigs\?/.test(url)) return ok({ value: [remoteConfig] });
if (method === "GET" && /\/connections\?/.test(url)) return ok({ value: [connection] });
if (method === "DELETE" && url.includes("/mcpserverConfigs/")) return ok({});
if (method === "GET" && url.includes("/mcpserverConfigs/")) {
return { ok: false, status: 404, text: async () => "gone" };
}
if (method === "DELETE" && url.includes("/connections/")) {
return { ok: false, status: 400, text: async () => "delete denied" };
}
throw new Error(`unexpected ARM call: ${method} ${url}`);
};
t.after(() => {
globalThis.fetch = realFetch;
});
await assert.rejects(uninstallConnector(config, "docusign"), /delete denied/);
const configDelete = operations.findIndex((item) => item.startsWith("DELETE ") && item.includes("/mcpserverConfigs/"));
const connectionDelete = operations.findIndex((item) => item.startsWith("DELETE ") && item.includes("/connections/"));
assert.ok(configDelete !== -1 && configDelete < connectionDelete, "configs must be confirmed deleted before their connections");
const pendingCleanup = join(TMP, "extensions", "connector-namespaces", "artifacts", "pending-cleanup");
assert.equal(readdirSync(pendingCleanup).filter((name) => name.endsWith(".json")).length, 1, "failed deletion must persist enough state to retry");
globalThis.fetch = async (urlArg, opts = {}) => {
const url = String(urlArg);
const method = (opts.method || "GET").toUpperCase();
const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) });
if (method === "GET" && /\/mcpserverConfigs\?/.test(url)) return ok({ value: [] });
if (method === "GET" && /\/connections\?/.test(url)) return ok({ value: [] });
if (method === "DELETE") return ok({});
if (method === "GET" && url.includes("/mcpserverConfigs/")) {
return { ok: false, status: 404, text: async () => "gone" };
}
throw new Error(`unexpected ARM call: ${method} ${url}`);
};
assert.deepEqual(await uninstallConnector(config, "docusign"), { ok: true, removed: true });
assert.equal(readdirSync(pendingCleanup).filter((name) => name.endsWith(".json")).length, 0, "successful retry must clear the cleanup journal");
});
test("uninstall surfaces convergence polling failures", async (t) => {
writeFileSync(
join(TMP, "mcp-config.json"),
JSON.stringify({ mcpServers: { "docusign-bbb": { type: "http", url: "https://example/mcp" } } }),
);
const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "gw1" };
const remoteConfig = { name: "docusign-bbb", properties: { connectors: [{ name: "docusign", connectionName: "conn-b" }] } };
const connection = { name: "conn-b", properties: { statuses: [{ status: "Connected" }] } };
const realFetch = globalThis.fetch;
globalThis.fetch = async (urlArg, opts = {}) => {
const url = String(urlArg);
const method = (opts.method || "GET").toUpperCase();
const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) });
if (method === "GET" && /\/mcpserverConfigs\?/.test(url)) return ok({ value: [remoteConfig] });
if (method === "GET" && url.includes("/mcpserverConfigs/")) {
return { ok: false, status: 400, text: async () => "poll denied" };
}
if (method === "GET" && /\/connections\?/.test(url)) return ok({ value: [connection] });
if (method === "DELETE") return ok({});
throw new Error(`unexpected ARM call: ${method} ${url}`);
};
t.after(() => {
globalThis.fetch = realFetch;
});
await assert.rejects(uninstallConnector(config, "docusign"), /poll denied/);
});
test("concurrent failed uninstalls retain independent retry records", async (t) => {
const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "concurrent-gw" };
const configs = [
{ name: "alpha-config", properties: { connectors: [{ name: "alpha", connectionName: "alpha-conn" }] } },
{ name: "beta-config", properties: { connectors: [{ name: "beta", connectionName: "beta-conn" }] } },
];
const connections = [
{ name: "alpha-conn", properties: { statuses: [{ status: "Connected" }] } },
{ name: "beta-conn", properties: { statuses: [{ status: "Connected" }] } },
];
const realFetch = globalThis.fetch;
globalThis.fetch = async (urlArg, opts = {}) => {
const url = String(urlArg);
const method = (opts.method || "GET").toUpperCase();
const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) });
if (method === "GET" && /\/mcpserverConfigs\?/.test(url)) return ok({ value: configs });
if (method === "GET" && /\/connections\?/.test(url)) return ok({ value: connections });
if (method === "DELETE" && url.includes("/mcpserverConfigs/")) return ok({});
if (method === "GET" && url.includes("/mcpserverConfigs/")) {
return { ok: false, status: 404, text: async () => "gone" };
}
if (method === "DELETE" && url.includes("/connections/")) {
return { ok: false, status: 400, text: async () => "delete denied" };
}
throw new Error(`unexpected ARM call: ${method} ${url}`);
};
t.after(() => {
globalThis.fetch = realFetch;
});
const results = await Promise.allSettled([
uninstallConnector(config, "alpha"),
uninstallConnector(config, "beta"),
]);
assert.deepEqual(results.map((result) => result.status), ["rejected", "rejected"]);
const pendingCleanup = join(TMP, "extensions", "connector-namespaces", "artifacts", "pending-cleanup");
const paths = readdirSync(pendingCleanup)
.filter((name) => name.endsWith(".json"))
.map((name) => join(pendingCleanup, name));
const records = paths.map((path) => ({ path, ...JSON.parse(readFileSync(path, "utf8")) }))
.filter((record) => record.gatewayId.includes("/connectorGateways/concurrent-gw"));
assert.deepEqual(new Set(records.map((record) => record.apiName)), new Set(["alpha", "beta"]));
for (const record of records) unlinkSync(record.path);
});
test("local MCP config read failures block cleanup", async () => {
const configPath = join(TMP, "mcp-config.json");
writeFileSync(configPath, "{invalid json");
try {
await assert.rejects(removeMcpEntry("docusign-bbb"), SyntaxError);
} finally {
writeFileSync(configPath, JSON.stringify({ mcpServers: {} }));
}
});
test("installed state propagates local MCP config read failures", async (t) => {
const configPath = join(TMP, "mcp-config.json");
writeFileSync(configPath, "{invalid json");
const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "state-fail-gw" };
const realFetch = globalThis.fetch;
globalThis.fetch = async (urlArg, opts = {}) => {
const url = String(urlArg);
const method = (opts.method || "GET").toUpperCase();
const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) });
if (method === "GET" && /\/mcpserverConfigs\?/.test(url)) return ok({ value: [] });
if (method === "GET" && /\/connections\?/.test(url)) return ok({ value: [] });
throw new Error(`unexpected ARM call: ${method} ${url}`);
};
t.after(() => {
globalThis.fetch = realFetch;
writeFileSync(configPath, JSON.stringify({ mcpServers: {} }));
});
await assert.rejects(getInstalledState(config), SyntaxError);
});
test("missing-connection reauth journals cleanup and the next install retries it", async (t) => {
const configPath = join(TMP, "mcp-config.json");
writeFileSync(
configPath,
JSON.stringify({ mcpServers: { "missing-config": { type: "http", url: "https://example/mcp" } } }),
);
const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "missing-conn-gw" };
const remoteConfig = {
name: "missing-config",
properties: { connectors: [{ name: "missing-api", connectionName: "missing-conn" }] },
};
const realFetch = globalThis.fetch;
let retrying = false;
globalThis.fetch = async (urlArg, opts = {}) => {
const url = String(urlArg);
const method = (opts.method || "GET").toUpperCase();
const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) });
if (method === "DELETE" && url.includes("/mcpserverConfigs/")) return ok({});
if (method === "GET" && url.includes("/mcpserverConfigs/missing-config")) {
return { ok: false, status: 404, text: async () => "gone" };
}
if (method === "DELETE" && url.includes("/connections/missing-conn")) {
return { ok: false, status: 404, text: async () => "gone" };
}
if (retrying && method === "GET" && url.includes("/managedApis/missing-api")) {
return { ok: false, status: 400, text: async () => "stop after cleanup" };
}
if (method === "GET" && /\/mcpserverConfigs\?/.test(url)) return ok({ value: [remoteConfig] });
if (method === "GET" && /\/connections\?/.test(url)) return ok({ value: [] });
if (method === "GET" && /\/connectorGateways\/[^/?]+\?/.test(url)) return ok({ location: "eastus" });
if (method === "GET" && url.includes("/managedApis/missing-api")) return ok({ properties: {} });
if (method === "POST" && url.includes("/connections/missing-conn/listConsentLinks")) {
writeFileSync(configPath, "{invalid json");
return { ok: false, status: 404, text: async () => "connection gone" };
}
throw new Error(`unexpected ARM call: ${method} ${url}`);
};
t.after(() => {
globalThis.fetch = realFetch;
writeFileSync(configPath, JSON.stringify({ mcpServers: {} }));
});
await assert.rejects(
reauthConnector(config, "missing-api", "Missing API", "https://cb/?c="),
SyntaxError,
);
const pendingCleanup = join(TMP, "extensions", "connector-namespaces", "artifacts", "pending-cleanup");
const matchingRecords = () => readdirSync(pendingCleanup)
.filter((name) => name.endsWith(".json"))
.map((name) => JSON.parse(readFileSync(join(pendingCleanup, name), "utf8")))
.filter((record) => record.gatewayId.includes("/connectorGateways/missing-conn-gw") && record.apiName === "missing-api");
assert.equal(matchingRecords().length, 1, "failed reauth cleanup must retain retry data");
writeFileSync(
configPath,
JSON.stringify({ mcpServers: { "missing-config": { type: "http", url: "https://example/mcp" } } }),
);
retrying = true;
await assert.rejects(
installConnector(config, "missing-api", "Missing API", "https://cb/?c="),
/stop after cleanup/,
);
assert.equal(matchingRecords().length, 0, "the next install must consume successful pending cleanup");
const localConfig = JSON.parse(readFileSync(configPath, "utf8"));
assert.equal(localConfig.mcpServers["missing-config"], undefined, "pending cleanup must remove the stale local entry");
});
test("fresh-connection rollback surfaces deletion failures", async (t) => {
const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "gw1" };
const realFetch = globalThis.fetch;
globalThis.fetch = async () => ({ ok: false, status: 400, text: async () => "rollback denied" });
t.after(() => {
globalThis.fetch = realFetch;
});
await assert.rejects(deleteConnection(config, "fresh-conn"), /rollback denied/);
});
test("finish status failures roll back the fresh connection", async (t) => {
const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "gw1" };
const realFetch = globalThis.fetch;
const calls = [];
globalThis.fetch = async (urlArg, opts = {}) => {
const url = String(urlArg);
const method = (opts.method || "GET").toUpperCase();
calls.push({ method, url });
const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) });
if (method === "GET" && url.includes("/managedApis/") && url.includes("export=true")) {
return ok({ paths: { "/mcp": { post: { operationId: "op", tags: ["agentic"] } } } });
}
if (method === "GET" && url.includes("/managedApis/")) return ok({ properties: {} });
if (method === "GET" && url.includes("/connections/fresh-status?")) {
return { ok: false, status: 400, text: async () => "status denied" };
}
if (method === "DELETE" && url.includes("/connections/fresh-status?")) return ok({});
throw new Error(`unexpected ARM call: ${method} ${url}`);
};
t.after(() => {
globalThis.fetch = realFetch;
});
await assert.rejects(
finishInstall(config, "status-fail", "Status Fail", "fresh-status", "eastus"),
/status denied/,
);
assert.ok(calls.some((call) => call.method === "DELETE" && call.url.includes("/connections/fresh-status?")));
});
test("failed config cleanup preserves its referenced connection", async (t) => {
const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "gw1" };
const realFetch = globalThis.fetch;
const calls = [];
globalThis.fetch = async (urlArg, opts = {}) => {
const url = String(urlArg);
const method = (opts.method || "GET").toUpperCase();
calls.push({ method, url });
const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) });
if (method === "GET" && url.includes("/managedApis/") && url.includes("export=true")) {
return ok({ paths: { "/mcp": { post: { operationId: "op", tags: ["agentic"] } } } });
}
if (method === "GET" && url.includes("/managedApis/")) return ok({ properties: {} });
if (method === "GET" && url.includes("/connections/fresh-config?")) {
return ok({ properties: { statuses: [{ status: "Connected" }] } });
}
if (method === "PUT" && url.includes("/mcpserverConfigs/")) {
return ok({ properties: { mcpEndpointUrl: "https://example.com/mcp" } });
}
if (method === "POST" && url.includes("/listApiKey?")) {
return { ok: false, status: 400, text: async () => "key denied" };
}
if (method === "DELETE" && url.includes("/mcpserverConfigs/")) {
return { ok: false, status: 400, text: async () => "config cleanup denied" };
}
if (method === "DELETE" && url.includes("/connections/")) return ok({});
throw new Error(`unexpected ARM call: ${method} ${url}`);
};
t.after(() => {
globalThis.fetch = realFetch;
});
await assert.rejects(
finishInstall(config, "cleanup-order", "Cleanup Order", "fresh-config", "eastus"),
/config cleanup denied/,
);
assert.ok(
!calls.some((call) => call.method === "DELETE" && call.url.includes("/connections/")),
"a surviving config must keep its referenced connection",
);
});
@@ -0,0 +1,253 @@
// Regression guards for install-state selection.
//
// Run: node --test extensions/connector-namespaces/install.test.mjs
//
// These exist because getInstalledState used to collapse N gateway configs for
// one apiName down to a single tile via ARM list order (last-wins). A portal
// add, a duplicate Connect, or a re-auth would mint a sibling config; whichever
// ARM happened to return last owned the tile, so a tile could show
// "Re-authenticate" while a different config for the same connector was already
// Connected. deriveInstalledState now picks deterministically:
// inCli && Connected > inCli > Connected > any, configName wins ties.
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { deriveInstalledState, getConsentUrl, getConnectionStatus, getMcpEndpointUrl, waitForConnected } from "./install.mjs";
// removeLocalEntry does file I/O (getInstalledState reads ARM + mcp configs,
// removeMcpEntry edits them) and calls both as same-module functions, so there
// is no import seam to stub. The invariant that matters — the default "Remove"
// only unlinks the CLI entry and NEVER deletes the Azure resource — is a
// source contract, so we assert it against the function body the same way
// renderer.test.mjs guards its CSS/HTML strings.
function functionBody(source, name) {
const exported = source.indexOf(`export async function ${name}(`);
const start = exported !== -1 ? exported : source.indexOf(`async function ${name}(`);
if (start === -1) return null;
const open = source.indexOf("{", start);
if (open === -1) return null;
let depth = 0;
for (let i = open; i < source.length; i++) {
const ch = source[i];
if (ch === "{") depth++;
else if (ch === "}") {
depth--;
if (depth === 0) return source.slice(open + 1, i);
}
}
return null;
}
const installSource = readFileSync(fileURLToPath(new URL("./install.mjs", import.meta.url)), "utf8");
// Build a fake ARM mcpserverConfig list entry.
function cfg(name, apiName, connName) {
return { name, properties: { connectors: [{ name: apiName, connectionName: connName }] } };
}
// Build a connName -> connection map with a given status.
function conns(...entries) {
const m = new Map();
for (const [connName, status] of entries) {
m.set(connName, { name: connName, properties: { statuses: [{ status }] } });
}
return m;
}
test("picks inCli+Connected over a not-inCli sibling that appears LAST (not last-wins)", () => {
// good config is FIRST; a broken sibling is LAST. Old last-wins would pick
// the last one — the fix must pick the good one regardless of order.
const configs = [
cfg("good", "shared-api", "connGood"),
cfg("bad", "shared-api", "connBad"),
];
const connByName = conns(["connGood", "Connected"], ["connBad", "Unknown"]);
const profileKeys = new Set(["good"]); // only the good config is in the CLI
const state = deriveInstalledState(configs, connByName, profileKeys, new Set(), null);
assert.equal(state["shared-api"].configName, "good");
assert.equal(state["shared-api"].connectionName, "connGood");
assert.equal(state["shared-api"].connectionStatus, "Connected");
assert.equal(state["shared-api"].inCli, true);
assert.equal(state["shared-api"]._configCount, 2);
assert.deepEqual(state["shared-api"]._candidates.map((item) => item.configName), ["good", "bad"]);
});
test("inCli beats a Connected-but-not-inCli sibling", () => {
// A is the config the local session points at but not yet Connected; B is
// Connected on ARM but not in the CLI. Prefer A so remove/re-auth act on the
// resource the user's session actually uses.
const configs = [
cfg("a-incli", "api", "connA"),
cfg("b-connected", "api", "connB"),
];
const connByName = conns(["connA", "Unknown"], ["connB", "Connected"]);
const state = deriveInstalledState(configs, connByName, new Set(["a-incli"]), new Set(), null);
assert.equal(state["api"].configName, "a-incli");
assert.equal(state["api"].inCli, true);
});
test("inCli && Connected beats inCli-only", () => {
const configs = [
cfg("incli-unknown", "api", "connU"),
cfg("incli-connected", "api", "connC"),
];
const connByName = conns(["connU", "Unknown"], ["connC", "Connected"]);
const state = deriveInstalledState(configs, connByName, new Set(["incli-unknown", "incli-connected"]), new Set(), null);
assert.equal(state["api"].configName, "incli-connected");
assert.equal(state["api"].connectionStatus, "Connected");
});
test("config name breaks equal-rank ties independently of ARM list order", () => {
const configs = [
cfg("z-config", "api", "connZ"),
cfg("a-config", "api", "connA"),
];
const connByName = conns(["connZ", "Connected"], ["connA", "Connected"]);
const local = new Set(["z-config", "a-config"]);
const forward = deriveInstalledState(configs, connByName, local, new Set(), null);
const reverse = deriveInstalledState([...configs].reverse(), connByName, local, new Set(), null);
assert.equal(forward.api.configName, "a-config");
assert.equal(reverse.api.configName, "a-config");
});
test("connection convergence reports non-connected terminal results as failures", async () => {
const states = ["Connecting", "Error"];
const delays = [];
await assert.rejects(
waitForConnected({}, "conn", {
maxPolls: 2,
getStatus: async () => states.shift(),
delay: async (ms) => delays.push(ms),
}),
/Connection ended in state "Error"/,
);
assert.deepEqual(delays, [1000]);
assert.equal(
await waitForConnected({}, "conn", {
getStatus: async () => "Connected",
delay: async () => assert.fail("connected state must not sleep"),
}),
"Connected",
);
});
test("single config passes through with no _configCount", () => {
const configs = [cfg("only", "api", "conn1")];
const connByName = conns(["conn1", "Connected"]);
const state = deriveInstalledState(configs, connByName, new Set(["only"]), new Set(), null);
assert.equal(state["api"].configName, "only");
assert.equal(state["api"]._configCount, undefined);
});
test("workspace membership counts as inCli and sets scope/path", () => {
const configs = [cfg("ws", "api", "conn1")];
const connByName = conns(["conn1", "Connected"]);
const state = deriveInstalledState(configs, connByName, new Set(), new Set(["ws"]), "/repo/.mcp.json");
assert.equal(state["api"].inCli, true);
assert.equal(state["api"].cliScope, "workspace");
assert.equal(state["api"].cliPath, "/repo/.mcp.json");
});
test("connectionStatus falls back to overallStatus then Unknown", () => {
const configs = [cfg("c1", "api1", "connOverall"), cfg("c2", "api2", "connMissing")];
const connByName = new Map([
["connOverall", { name: "connOverall", properties: { overallStatus: "Connected" } }],
]);
const state = deriveInstalledState(configs, connByName, new Set(), new Set(), null);
assert.equal(state["api1"].connectionStatus, "Connected"); // from overallStatus
assert.equal(state["api2"].connectionStatus, "Unknown"); // no connection at all
});
test("configs with no connector are skipped", () => {
const configs = [
{ name: "broken", properties: { connectors: [] } },
cfg("ok", "api", "conn1"),
];
const connByName = conns(["conn1", "Connected"]);
const state = deriveInstalledState(configs, connByName, new Set(["ok"]), new Set(), null);
assert.equal(Object.keys(state).length, 1);
assert.equal(state["api"].configName, "ok");
});
test("removeLocalEntry unlinks the local CLI entry via removeMcpEntry", () => {
const body = functionBody(installSource, "removeLocalEntry");
assert.ok(body, "removeLocalEntry function not found in install.mjs");
assert.match(body, /removeMcpEntry\s*\(/, "removeLocalEntry must call removeMcpEntry to drop the CLI entry");
assert.match(body, /entry\._candidates/, "removeLocalEntry must process duplicate CLI configs");
assert.match(body, /candidate\.inCli/, "removeLocalEntry must unlink every local candidate");
});
test("uninstallConnector deletes every duplicate namespace config", () => {
const body = functionBody(installSource, "uninstallConnector");
const cleanup = functionBody(installSource, "cleanupConnectorResources");
assert.ok(body, "uninstallConnector function not found in install.mjs");
assert.ok(cleanup, "cleanupConnectorResources function not found in install.mjs");
assert.match(body, /entry\._candidates/, "namespace deletion must process duplicate configs");
assert.match(body, /cleanupConnectorResources\s*\(/, "uninstall must delegate all collected candidates to shared cleanup");
assert.match(cleanup, /deleteMcpServerConfigs\(config, configNames\)/);
assert.match(cleanup, /for \(const connectionName of connectionNames\)/);
assert.match(cleanup, /for \(const configName of configNames\)/);
});
test("removeLocalEntry never deletes the namespace resource (no armDelete)", () => {
const body = functionBody(installSource, "removeLocalEntry");
assert.ok(body, "removeLocalEntry function not found in install.mjs");
// The default Remove must stay local-only. If someone routes it through
// uninstallConnector or adds an ARM delete, this fails — which is the point.
assert.doesNotMatch(body, /armDelete\s*\(/, "removeLocalEntry must not call armDelete");
assert.doesNotMatch(body, /uninstallConnector\s*\(/, "removeLocalEntry must not delegate to uninstallConnector");
});
// --- ARM path-injection guard (client-reachable read sinks) ---
//
// finishInstall/finishReauth feed client-supplied body.connName / body.configName
// into getConsentUrl, getConnectionStatus, and getMcpEndpointUrl, which build ARM
// URLs. Those names must pass through armSegment() so a traversal / query payload
// can't escape the intended resource path (SSRF / path injection). armSegment
// throws synchronously while the URL is built, before any token or network call,
// so these run fully offline and deterministic. A valid config is used so the
// gatewayId() wrap doesn't throw first — only the bad NAME should reject.
const validConfig = { subscriptionId: "s", resourceGroup: "r", gatewayName: "g" };
const badNames = ["../../evil", "evil/../../secret", "x?injected=1"];
test("getConnectionStatus rejects traversal/injection connName before any ARM call", async () => {
for (const bad of badNames) {
await assert.rejects(
() => getConnectionStatus(validConfig, bad),
/Invalid ARM resource identifier/,
`getConnectionStatus should reject connName ${JSON.stringify(bad)}`,
);
}
});
test("getConsentUrl rejects traversal/injection connName before any ARM call", async () => {
for (const bad of badNames) {
await assert.rejects(
() => getConsentUrl(validConfig, bad, "http://127.0.0.1:0/auth/callback/x"),
/Invalid ARM resource identifier/,
`getConsentUrl should reject connName ${JSON.stringify(bad)}`,
);
}
});
test("getMcpEndpointUrl rejects traversal/injection configName before any ARM call", async () => {
for (const bad of badNames) {
await assert.rejects(
() => getMcpEndpointUrl(validConfig, bad),
/Invalid ARM resource identifier/,
`getMcpEndpointUrl should reject configName ${JSON.stringify(bad)}`,
);
}
});
@@ -0,0 +1,58 @@
import assert from "node:assert/strict";
import { createServer } from "node:http";
import test from "node:test";
import { probe } from "./test/mcp-probe.mjs";
test("native HTTP probe carries API key and MCP session through an SSE handshake", async (t) => {
const apiKeys = [];
const sessionIds = [];
const methods = [];
const server = createServer(async (req, res) => {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const message = JSON.parse(Buffer.concat(chunks).toString("utf8"));
apiKeys.push(req.headers["x-api-key"]);
sessionIds.push(req.headers["mcp-session-id"] || null);
methods.push(message.method);
if (message.method === "notifications/initialized") {
res.writeHead(202);
res.end();
return;
}
let result;
if (message.method === "initialize") {
res.setHeader("Mcp-Session-Id", "session-1");
result = {
protocolVersion: "2025-06-18",
serverInfo: { name: "test-server", version: "1.0.0" },
capabilities: { tools: {} },
};
} else if (message.method === "tools/list") {
result = { tools: [{ name: "ListTeams", inputSchema: { type: "object" } }] };
} else {
result = { content: [{ type: "text", text: "ok" }] };
}
res.setHeader("Content-Type", "text/event-stream");
res.end(`event: message\ndata: ${JSON.stringify({ jsonrpc: "2.0", id: message.id, result })}\n\n`);
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
t.after(() => new Promise((resolve) => server.close(resolve)));
const { port } = server.address();
const result = await probe({
apiName: "WorkIQTeams",
displayName: "WorkIQ Teams",
url: `http://127.0.0.1:${port}/mcp`,
key: "secret",
});
assert.equal(result.ok, true);
assert.equal(result.toolCount, 1);
assert.equal(result.toolCalled, "ListTeams");
assert.deepEqual(methods, ["initialize", "notifications/initialized", "tools/list", "tools/call"]);
assert.deepEqual(apiKeys, ["secret", "secret", "secret", "secret"]);
assert.deepEqual(sessionIds, [null, "session-1", "session-1", "session-1"]);
});
@@ -0,0 +1,19 @@
{
"name": "connector-namespaces",
"version": "1.1.0",
"type": "module",
"main": "extension.mjs",
"description": "Browse, connect, and open MCP connectors from your Azure Connector Namespace in Sandbox.",
"keywords": [
"azure",
"connector-namespace",
"mcp",
"mcp-connectors",
"model-context-protocol",
"tool-discovery"
],
"license": "MIT",
"dependencies": {
"@github/copilot-sdk": "1.0.6"
}
}
@@ -0,0 +1,2 @@
# Screenshot evidence is throwaway, regenerated on demand by shots.mjs.
shots/
@@ -0,0 +1,112 @@
# connector-namespaces preview harness
A standalone way to **see** every canvas state without launching the Copilot
app. It imports the real, pure renderer functions from `../renderer.mjs` and
serves each state on a fixed loopback port, with every `/api/*` endpoint stubbed
so you can force the states that keep regressing (the connecting spinner and the
"Restart your Copilot session…" banner).
This exists because those two bugs have each shipped multiple times:
- the sign-in spinner freezing (an unscoped `animation:none` leaking out of the
reduced-motion block), and
- the restart-banner dismiss button doing nothing (a CSS specificity bug that
let `.restart-banner{display:flex}` beat `[hidden]`).
Both are static CSS facts, so the **deterministic gate is `../renderer.test.mjs`**
(run with `node --test`). This harness is the human-visual layer on top of it:
load a state in a browser, or capture screenshots with `agent-browser`.
## Run the preview server
```sh
node extensions/connector-namespaces/preview/server.mjs
```
It binds to `http://127.0.0.1:7331`. Open that URL in any browser. The server is
a plain HTTP process (not the JSON-RPC extension provider), so it logs every hit
to stdout — that's expected and fine here.
### State routes
| URL | State |
| --- | --- |
| `/` or `/catalog` | Configured catalog (mock gateway + connectors) |
| `/setup` | First-run gateway picker (`renderSetupHtml`) |
| `/error` | Error screen (`renderErrorHtml`) |
### State-forcing query flags (on the catalog route)
The catalog page hydrates from `/api/state` on load, so loading one of these
sets the state the very next `/api/state` returns:
| Flag | Effect |
| --- | --- |
| `/?restart=1` | `/api/state` returns `pendingRestart:true` → restart banner visible on load |
| `/?installed=1` | One connector shows as already installed/connected |
Flags combine, e.g. `/?installed=1&restart=1`.
> The active state is a single module-level flag (last catalog load wins). It's a
> single-user preview, so just load the page you want, then it's sticky until the
> next catalog load.
### Stubbed endpoints
`/api/state`, `/api/gateways`, `/api/select-gateway`, `/api/install` (returns
`needsConsent` to force the connecting spinner), `/api/finish-install`,
`/api/ack-restart` (the dismiss action), `/oauth-status` (stays pending so the
modal spinner keeps animating), `/api/uninstall`, `/api/rollback-connection`,
and `/api/open-url` (a deliberate **no-op** here — it must never actually launch
a browser tab).
## Capture screenshots (optional)
The screenshot driver uses [`agent-browser`](https://www.npmjs.com/package/agent-browser),
the same headless-Chromium verification tool that `arikbidny/ralph-copilot-cli`
uses. It is **not** required — if it isn't installed the driver prints an install
hint and exits 0.
Install it once:
```sh
npm i -g agent-browser && agent-browser install
```
Then, with the server running in another terminal:
```sh
node extensions/connector-namespaces/preview/shots.mjs
```
Screenshots are written to `preview/shots/`:
- `catalog.png`, `catalog-restart-banner.png`, `catalog-installed.png`,
`setup.png`, `error.png` — the static states.
- `connecting-spinner.png` — after clicking **Connect**; verify the `.si-spin`
ring is mid-rotation, not frozen.
- `banner-before-dismiss.png` / `banner-after-dismiss.png` — verify the banner is
present in the first and **gone** in the second.
`preview/shots/` is throwaway visual evidence; it is not committed.
## Files
| File | Purpose |
| --- | --- |
| `server.mjs` | Standalone preview server (fixed port 7331) |
| `fixtures.mjs` | Deterministic mock subscriptions / gateways / catalog / state |
| `shots.mjs` | `agent-browser` screenshot driver (degrades gracefully) |
## Relationship to the test guard
`shots.mjs` proves a state *looks* right today and is handy when chasing a new
bug. It cannot prove an animation is *running* from a single frame. The
regression gate that actually blocks the recurring bugs is the CSS-structure
assertion in `../renderer.test.mjs`:
```sh
node --test extensions/connector-namespaces/renderer.test.mjs
```
Keep that green; use this harness to eyeball changes.
@@ -0,0 +1,117 @@
// Deterministic fixtures for the standalone canvas preview server.
//
// These mirror the exact response shapes the inline client script in
// renderer.mjs expects, so the preview server can drive every canvas state
// (setup / catalog / error / connecting-spinner / restart-banner) with no
// Copilot app, no ARM, and no real OAuth. Keep these shapes in sync with the
// fetch() handlers in renderer.mjs if those response contracts change.
import { CATEGORY } from "../categories.mjs";
export const subscriptions = [
{ id: "00000000-0000-0000-0000-000000000001", name: "Contoso Production" },
{ id: "11111111-1111-1111-1111-111111111111", name: "Contoso Dev/Test" },
];
// /api/gateways?subscriptionId=... -> { gateways: [{ id, name, location }], hasMore }
// The client splits id on "/" and reads the segment after "resourceGroups",
// so the id must contain a resourceGroups segment.
export const gateways = [
{
id: "/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/rg-connectors/providers/Microsoft.ConnectorNamespaces/connectorNamespaces/contoso-ns",
name: "contoso-ns",
location: "eastus",
},
{
id: "/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/rg-shared/providers/Microsoft.ConnectorNamespaces/connectorNamespaces/shared-ns",
name: "shared-ns",
location: "westus2",
},
];
// Active namespace shown in the catalog header (config.gatewayName / resourceGroup).
export const config = {
subscriptionId: "00000000-0000-0000-0000-000000000001",
gatewayName: "contoso-ns",
resourceGroup: "rg-connectors",
};
// Catalog tiles. Shape per item: { category, displayName, apiName, description,
// iconUri?, brandColor? }. At least one item must be connectable so the
// connect -> spinner flow can be exercised.
//
// The renderer routes items by category: exactly `category === CATEGORY.microsoft`
// lands in the Microsoft section, everything else in Partners. Keep a mix of
// both here so the preview exercises the full 3-section layout (My MCPs /
// Microsoft / Partners) rather than dumping every tile into one section.
export const catalog = [
{
category: CATEGORY.microsoft,
displayName: "Microsoft Teams",
apiName: "teams",
description: "Send messages, manage chats and channels.",
brandColor: "#5059c9",
},
{
category: CATEGORY.microsoft,
displayName: "Outlook Mail",
apiName: "outlook",
description: "Read, send, and organize email.",
brandColor: "#0a66c2",
},
{
category: CATEGORY.microsoft,
displayName: "SharePoint",
apiName: "sharepoint",
description: "Browse sites, lists, and documents.",
brandColor: "#038387",
},
{
category: CATEGORY.partner,
displayName: "GitHub",
apiName: "github",
description: "Manage repos, issues, and pull requests.",
brandColor: "#24292e",
},
{
category: CATEGORY.partner,
displayName: "Stripe",
apiName: "stripe",
description: "Payments, customers, and invoices.",
brandColor: "#635bff",
},
];
// /api/state -> { state: { apiName: InstallState }, pendingRestart }
// InstallState: { installed, connectionStatus, inCli, cliPath?, cliScope? }
// Default state: nothing installed, no pending restart. The catalog renders
// every tile with a "Connect" button.
export const stateEmpty = {
state: {},
pendingRestart: false,
};
// One connector already added (shows "Added" + Remove), restart pending so the
// banner is visible on load. Drives both the "added" tile and the banner state.
export const stateInstalledRestart = {
state: {
sharepoint: {
installed: true,
connectionStatus: "Connected",
inCli: true,
cliPath: "~/.copilot/mcp-config.json",
cliScope: "profile",
},
},
pendingRestart: true,
};
// Install response that forces the connecting flow. needsConsent keeps the
// sign-in modal (with the .si-spin spinner) open; /oauth-status then stays
// pending so the spinner keeps animating for a screenshot.
export const installNeedsConsent = {
needsConsent: true,
connName: "preview-conn",
consentUrl: "http://127.0.0.1:7331/fake-consent",
location: "eastus",
};
@@ -0,0 +1,151 @@
// Standalone preview server for the connector-namespaces connector catalog.
//
// Renders every canvas state with no Copilot app, no ARM, and no real OAuth by
// importing the *pure* HTML builders from renderer.mjs and stubbing every
// /api/* endpoint the inline client script calls. Point any browser (or the
// agent-browser driver in shots.mjs) at it to see exactly what ships.
//
// Run: node extensions/connector-namespaces/preview/server.mjs
// Then open http://127.0.0.1:7331/ (catalog), /setup, /error.
//
// This process is NOT the JSON-RPC extension provider, so console.log here is
// fine and intentional — it is how you watch which stubbed endpoints get hit.
import { createServer } from "node:http";
import {
renderCatalogHtml,
renderSetupHtml,
renderErrorHtml,
} from "../renderer.mjs";
import * as fixtures from "./fixtures.mjs";
const HOST = "127.0.0.1";
const PORT = 7331;
const INSTANCE = "preview";
// Whatever /api/state should report next. The catalog route updates this from
// its query flags so a page load can force the banner / "added" tile on, and a
// real Connect click flips pendingRestart on via showRestartBanner().
let activeState = fixtures.stateEmpty;
function selectState(query) {
const restart = query.get("restart") === "1";
const installed = query.get("installed") === "1";
if (restart && installed) return fixtures.stateInstalledRestart;
if (installed) return { state: fixtures.stateInstalledRestart.state, pendingRestart: false };
if (restart) return { state: {}, pendingRestart: true };
return fixtures.stateEmpty;
}
function sendHtml(res, body) {
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(body);
}
function sendJson(res, obj, status = 200) {
res.statusCode = status;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify(obj));
}
async function readBody(req) {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
if (!chunks.length) return {};
try {
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
} catch {
return {};
}
}
const server = createServer(async (req, res) => {
const url = new URL(req.url, `http://${HOST}:${PORT}`);
const path = url.pathname;
const q = url.searchParams;
// Strip CR/LF/tab so a crafted request line can't forge extra log entries.
console.log(`${req.method} ${req.url}`.replace(/[\r\n\t]/g, " "));
// --- Page routes ---------------------------------------------------------
if (req.method === "GET" && (path === "/" || path === "/catalog")) {
activeState = selectState(q);
return sendHtml(
res,
renderCatalogHtml(INSTANCE, fixtures.catalog, {
filter: q.get("filter") || "",
category: q.get("category") || "all",
source: q.get("source") || "",
config: fixtures.config,
}),
);
}
if (req.method === "GET" && path === "/setup") {
return sendHtml(res, renderSetupHtml(fixtures.subscriptions));
}
if (req.method === "GET" && path === "/error") {
return sendHtml(res, renderErrorHtml(q.get("message") || "Something went wrong loading connectors."));
}
if (req.method === "GET" && path === "/fake-consent") {
return sendHtml(res, "<!doctype html><meta charset=utf-8><title>Consent</title><body style=\"font-family:sans-serif;padding:2rem\">Fake Microsoft consent page (preview). Close this tab.</body>");
}
// --- 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.");
});
@@ -0,0 +1,96 @@
// agent-browser screenshot driver for the canvas preview server.
//
// Captures every canvas state to ./shots/ and drives the two interaction flows
// that keep regressing:
// 1. catalog -> click Connect -> sign-in modal with the spinning .si-spin
// 2. restart banner visible -> click dismiss -> banner gone
//
// Requires the preview server to be running:
// node extensions/connector-namespaces/preview/server.mjs
// And agent-browser installed:
// npm i -g agent-browser && agent-browser install
//
// If agent-browser is not installed, this script prints how to install it and
// exits 0 (so it never breaks an unattended run). This is a visual-evidence
// helper; the deterministic regression gate is renderer.test.mjs.
import { spawnSync } from "node:child_process";
import { mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const HERE = dirname(fileURLToPath(import.meta.url));
const SHOTS = join(HERE, "shots");
const BASE = "http://127.0.0.1:7331";
function hasAgentBrowser() {
const probe = spawnSync("agent-browser", ["--version"], { encoding: "utf8", shell: true });
return probe.status === 0;
}
function ab(args) {
const r = spawnSync("agent-browser", args, { encoding: "utf8", shell: true });
if (r.status !== 0) {
console.error(`agent-browser ${args.join(" ")} failed:\n${r.stderr || r.stdout}`);
}
return r;
}
function serverUp() {
// Node 18+ has global fetch. Confirm the preview server is reachable.
return fetch(`${BASE}/api/state`).then(() => true).catch(() => false);
}
async function main() {
if (!hasAgentBrowser()) {
console.log("agent-browser is not installed -> skipping screenshots.");
console.log("Install it with: npm i -g agent-browser && agent-browser install");
console.log("Then re-run: node extensions/connector-namespaces/preview/shots.mjs");
process.exit(0);
}
if (!(await serverUp())) {
console.error("preview server is not reachable at " + BASE);
console.error("start it first: node extensions/connector-namespaces/preview/server.mjs");
process.exit(1);
}
mkdirSync(SHOTS, { recursive: true });
// Static states.
const states = [
["catalog", `${BASE}/`],
["catalog-restart-banner", `${BASE}/?restart=1`],
["catalog-installed", `${BASE}/?installed=1`],
["setup", `${BASE}/setup`],
["error", `${BASE}/error`],
];
for (const [name, target] of states) {
ab(["open", target]);
ab(["screenshot", join(SHOTS, `${name}.png`)]);
console.log(`captured ${name}`);
}
// Flow 1: connect -> connecting spinner. The preview /api/install returns
// needsConsent and /oauth-status stays pending, so the .si-spin modal
// spinner keeps animating. Best-effort selector; adjust if markup changes.
ab(["open", `${BASE}/`]);
ab(["click", ".item-add[data-api]"]);
ab(["screenshot", join(SHOTS, "connecting-spinner.png")]);
console.log("captured connecting-spinner (verify the spinner is mid-rotation)");
// Flow 2: banner -> dismiss -> gone. Screenshot before and after the click
// so a frozen/broken dismiss button is visible as a diff.
ab(["open", `${BASE}/?restart=1`]);
ab(["screenshot", join(SHOTS, "banner-before-dismiss.png")]);
ab(["click", ".restart-banner .rb-dismiss"]);
ab(["screenshot", join(SHOTS, "banner-after-dismiss.png")]);
console.log("captured banner-before-dismiss / banner-after-dismiss (after should have no banner)");
console.log(`\nshots written to ${SHOTS}`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,393 @@
// Regression guards for the connector-catalog renderer.
//
// Run: node --test extensions/connector-namespaces/renderer.test.mjs
//
// These tests exist because two UX bugs kept coming back:
// 1. A `@media (prefers-reduced-motion: reduce)` rule froze functional
// loaders without a visible fallback. Reduced motion now stops the
// animation while forcing each loader into a visible static busy state;
// nearby text continues to communicate progress.
// 2. The "Restart your Copilot session" banner ignoring Dismiss. The real
// root cause was CSS specificity: `.restart-banner{display:flex}` is an
// author rule with the same (0,1,0) specificity as the UA
// `[hidden]{display:none}` rule, so it overrode the hidden attribute and
// `restartBanner.hidden=true` did nothing. The fix is a global
// `[hidden]{display:none !important}` reset. A client-side
// `restartDismissed` flag also keeps a late hydrateState() from re-showing
// it. The guards below fail if either the CSS reset or the JS gate
// disappears.
import { test } from "node:test";
import assert from "node:assert/strict";
import { baseStyles, renderCatalogHtml, renderSetupHtml } from "./renderer.mjs";
import { renderCreateNamespaceHtml } from "./createPage.mjs";
import { CATEGORY } from "./categories.mjs";
// Pull the balanced body of the prefers-reduced-motion media block out of a
// stylesheet string (non-greedy regex can't handle the nested rule braces).
// CSS comments are stripped so the guards test declarations rather than prose.
function reducedMotionBlock(css) {
const start = css.indexOf("@media (prefers-reduced-motion: reduce)");
if (start === -1) return null;
const open = css.indexOf("{", start);
if (open === -1) return null;
let depth = 0;
for (let i = open; i < css.length; i++) {
if (css[i] === "{") depth++;
else if (css[i] === "}" && --depth === 0) {
return css.slice(open + 1, i).replace(/\/\*[\s\S]*?\*\//g, "");
}
}
return null;
}
function catalogHtml() {
return renderCatalogHtml("test-instance", [], {
filter: "",
category: "all",
source: "",
config: { subscriptionId: "sub", gatewayName: "ns", resourceGroup: "rg" },
});
}
test("setup subscription label names its select", () => {
const html = renderSetupHtml([], "", "token");
assert.match(html, /<label for="sub-select">Subscription<\/label>/);
});
test("load-all and installed-state failures stay visible and fail closed", () => {
const setup = renderSetupHtml([], "", "token");
const catalog = catalogHtml();
assert.match(setup, /if \(data\.error\) throw new Error\(data\.error\)/, "Load all must enter its retryable failure path");
assert.match(setup, /if \(!await loadAll\(\)\) return/, "filtering must preserve the retry UI after Load all fails");
assert.match(
catalog,
/document\.querySelectorAll\("\.item-add"\)\.forEach\(button => \{ button\.disabled = true; \}\)/,
"actions must remain disabled until installed state is known",
);
assert.match(catalog, /Couldn't load connector state:/, "state failures must be shown to the user");
});
test("OAuth failures roll back only fresh connections and reconcile ambiguous finishes", () => {
const html = catalogHtml();
assert.match(html, /freshConnection = data\.freshConnection === true/);
assert.match(html, /if \(finishStarted && canReconcileFinish\)/, "only eligible finish failures may enter reconciliation");
assert.match(html, /reconcileFinishedInstall\(apiName, connName\)/);
assert.match(html, /!finishResponseReceived && complete/, "explicit finish errors must never be reclassified as success");
assert.match(html, /state\.connectionName === connName/, "ambiguous finishes must match the exact connection");
assert.match(html, /state\.connectionStatus === "Connected"/, "ambiguous finishes require a connected matching install");
assert.match(html, /if \(freshConnection && connName\)/, "definite pre-finish failures must clean up owned connections");
const reconciliation = html.slice(
html.indexOf("async function reconcileFinishedInstall"),
html.indexOf("async function recoverConnectorFailure"),
);
assert.doesNotMatch(reconciliation, /rollbackFreshConnection/, "ambiguous post-finish state must never delete a connection");
const connect = html.slice(html.indexOf("async function onConnect"), html.indexOf("async function onReauth"));
const reauth = html.slice(html.indexOf("async function onReauth"), html.indexOf("async function hydrateState"));
assert.match(connect, /finishResponseReceived,\s*true\s*\)/, "fresh Connect may reconcile an ambiguous finish");
assert.match(reauth, /finishResponseReceived,\s*freshConnection\s*\)/, "existing re-auth must not reuse its pre-existing state as proof of success");
});
test("failed install and re-auth requests always refresh fail-closed state", () => {
const html = catalogHtml();
const connectFailure = html.slice(html.indexOf("async function onConnect"), html.indexOf("async function onReauth"));
const reauthFailure = html.slice(html.indexOf("async function onReauth"), html.indexOf("async function hydrateState"));
assert.match(connectFailure, /await hydrateState\(\)/);
assert.match(reauthFailure, /await hydrateState\(\)/);
assert.doesNotMatch(html, /if \(cancelled \|\| finishStarted \|\| freshConnection\)/);
assert.match(connectFailure, /postIdempotentMutation\("\/api\/install"/);
assert.match(reauthFailure, /postIdempotentMutation\("\/api\/reauth"/);
assert.match(html, /for \(let attempt = 0; attempt < 2; attempt\+\+\)/, "ambiguous initial responses must replay the same request");
assert.match(html, /crypto\.getRandomValues\(bytes\)/, "every mutation needs a client-known replay id");
});
test("failed disconnect and namespace deletion refresh fail-closed state", () => {
const html = catalogHtml();
const disconnect = html.slice(html.indexOf("async function onRemoveLocal"), html.indexOf("function ensureDeleteDialog"));
const namespaceDelete = html.slice(html.indexOf("async function performNamespaceDelete"), html.indexOf("function waitForOAuth"));
assert.match(disconnect, /catch \(err\)[\s\S]*await hydrateState\(\)/);
assert.match(namespaceDelete, /catch \(err\)[\s\S]*await hydrateState\(\)/);
assert.doesNotMatch(disconnect, /mainBtn\.disabled = false/);
assert.doesNotMatch(namespaceDelete, /b\.disabled = false/);
});
test("baseStyles defines the spin keyframes", () => {
assert.match(baseStyles(), /@keyframes spin\b/, "spinner keyframes must be defined");
});
test("reduced-motion block replaces infinite loaders with static busy states", () => {
const block = reducedMotionBlock(baseStyles());
assert.ok(block, "a prefers-reduced-motion media block must exist");
assert.match(block, /\.brand-loading, \.skeleton, \.si-spin, \.spin/);
assert.match(block, /animation:\s*none\s*!important/);
assert.match(block, /border-top-color:\s*currentColor\s*!important/);
assert.match(block, /\.brand-loading, \.skeleton\s*\{\s*opacity:\s*1;\s*transform:\s*none/);
});
test("catalog keeps textual progress beside animated-default loaders", () => {
const html = catalogHtml();
assert.match(html, /\.si-spin\b[^}]*animation:\s*spin/, "install overlay spinner must use the spin animation");
assert.match(html, /currentColor[^"]*animation:\s*spin/, "the Connect button spinner must use the spin animation");
assert.match(html, /Connecting/, "the Connect button should show progress text alongside its spinner");
});
test("restart banner dismiss is sticky against a racing state refresh", () => {
const html = catalogHtml();
// The client-side dismissal flag and its gate in hydrateState must survive.
assert.match(html, /restartDismissed\s*=\s*true/, "dismiss handler must set the sticky flag");
assert.match(
html,
/restartBanner\.hidden\s*=\s*restartDismissed\s*\|\|\s*!d\.pendingRestart/,
"hydrateState must respect the dismissed flag so a late refresh can't re-show the banner",
);
});
test("a global [hidden] reset makes the hidden attribute authoritative", () => {
// The actual dismiss bug: .restart-banner{display:flex} (an author rule)
// ties the UA [hidden]{display:none} rule on specificity and wins, so the
// hidden attribute is ignored. This reset must exist or Dismiss silently
// breaks again, no matter how correct the JS is.
assert.match(
baseStyles() + catalogHtml(),
/\[hidden\]\s*\{[^}]*display:\s*none\s*!important/,
"a [hidden]{display:none !important} reset must exist so el.hidden=true actually hides",
);
});
test("the setup picker surfaces a fallback notice only when given one", () => {
// When an open-time catalog fetch fails (saved namespace deleted, access
// revoked, transient outage), the server falls back to the picker with a
// "pick another" notice instead of a dead-end error page. The notice must
// render when passed and stay absent otherwise.
const subs = [{ id: "sub-1-abcdef", name: "Sub One" }];
const withNotice = renderSetupHtml(subs, "couldn't open namespace ns .. pick another to continue.");
assert.match(withNotice, /class="setup-notice"/, "the picker must render the notice banner when one is supplied");
assert.match(withNotice, /pick another to continue/, "the notice copy must reach the page");
const plain = renderSetupHtml(subs);
assert.doesNotMatch(plain, /class="setup-notice"/, "no notice banner should render on a normal setup visit");
});
test("namespace choices are keyboard-accessible buttons", () => {
const html = renderSetupHtml([{ id: "sub-1", name: "Sub One" }]);
assert.match(html, /<button type="button" class="setup-card"/);
assert.doesNotMatch(html, /<div class="setup-card"/);
assert.match(html, /\.setup-card:focus-visible/);
});
test("setup and create loaders ignore stale subscription responses", () => {
const setup = renderSetupHtml([{ id: "sub-1", name: "Sub One" }]);
assert.match(setup, /requestSeq !== gatewayRequestSeq \|\| subId !== subSelect\.value/);
const create = renderCreateNamespaceHtml([{ id: "sub-1", name: "Sub One" }]);
assert.match(create, /seq !== resourceGroupsSeq \|\| sub !== subSelect\.value/);
assert.match(create, /seq !== identitiesSeq \|\| sub !== subSelect\.value/);
});
test("create form invalidates pending checks immediately and uses unique identity ids", () => {
const html = renderCreateNamespaceHtml([{ id: "sub-1", name: "Sub One" }]);
assert.match(html, /clearTimeout\(nameTimer\);\s*checkSeq\+\+;/);
assert.match(html, /ids\.map\(\(id, index\) =>/);
assert.match(html, /id="uami-' \+ index/);
});
test("create form locks navigation and fields while provisioning", () => {
const html = renderCreateNamespaceHtml([{ id: "sub-1", name: "Sub One" }]);
assert.match(html, /for \(const control of document\.querySelectorAll\("button, input, select"\)\)/);
assert.match(html, /setFormLocked\(true\)/);
assert.match(html, /control\.disabled = true/);
assert.match(html, /document\.body\.setAttribute\("aria-busy"/);
assert.match(html, /finally \{\s*if \(creating\) setFormLocked\(true\)/);
assert.match(html, /if \(!creating\) window\.location\.href = "\/setup"/);
assert.match(html, /progress\.textContent = [^;]*request\.name/);
});
function catalogHtmlFull() {
// Non-empty fixture: one Microsoft item and one partner item, so the section
// partition, the move-model grids, and the collapsible heads can be asserted.
const catalog = [
{ id: "p1", apiName: "acme", displayName: "Acme Widgets", description: "partner thing", iconUri: "", brandColor: "", category: CATEGORY.partner },
{ id: "m1", apiName: "azureblob", displayName: "Azure Blob", description: "ms thing", iconUri: "", brandColor: "", category: CATEGORY.microsoft },
];
return renderCatalogHtml("test-instance", catalog, {
filter: "",
category: "all",
source: "",
config: { subscriptionId: "sub", gatewayName: "ns", resourceGroup: "rg" },
});
}
test("the catalog renders three collapsible sections in order", () => {
const html = catalogHtmlFull();
const iMine = html.indexOf('id="sec-mine"');
const iMs = html.indexOf('id="sec-microsoft"');
const iPartner = html.indexOf('id="sec-partner"');
assert.ok(iMine !== -1 && iMs !== -1 && iPartner !== -1, "all three sections must render");
assert.ok(iMine < iMs && iMs < iPartner, "sections must render in order: mine, microsoft, partner");
assert.match(html, /id="sec-mine"[\s\S]*?>My MCPs</, "mine section title");
assert.match(html, /id="sec-microsoft"[\s\S]*?>Microsoft</, "microsoft section title");
assert.match(html, /id="sec-partner"[\s\S]*?>Partners</, "partner section title");
});
test("Microsoft and partner items land in their home grids; My MCPs starts empty", () => {
const html = catalogHtmlFull();
// Server-rendered order is fixed (mine, microsoft, partner), so an item's
// index relative to the grid ids tells us which grid it sits in.
const iGridMs = html.indexOf('id="grid-microsoft"');
const iGridPartner = html.indexOf('id="grid-partner"');
const iMs = html.indexOf('data-api-item="azureblob"');
const iPartner = html.indexOf('data-api-item="acme"');
assert.ok(iMs > iGridMs && iMs < iGridPartner, "the Microsoft item must sit in #grid-microsoft");
assert.ok(iPartner > iGridPartner, "the partner item must sit in #grid-partner");
assert.match(html, /data-api-item="azureblob" data-home-grid="microsoft"/, "Microsoft item carries its home grid");
assert.match(html, /data-api-item="acme" data-home-grid="partner"/, "partner item carries its home grid");
assert.match(html, /id="grid-mine"[^>]*><\/div>/, "the My MCPs grid must be empty at render");
});
test("catalog only emits inline icon color for strict hex brand colors", () => {
const html = renderCatalogHtml("test-instance", [
{
id: "safe",
apiName: "safe",
displayName: "Safe",
description: "",
iconUri: "https://example.com/safe.png",
brandColor: "#5059c9",
category: CATEGORY.partner,
},
{
id: "bad",
apiName: "bad",
displayName: "Bad",
description: "",
iconUri: "https://example.com/bad.png",
brandColor: "#5059c9;background-image:url(https://attacker.example/pixel);color:",
category: CATEGORY.partner,
},
{
id: "short",
apiName: "short",
displayName: "Short",
description: "",
iconUri: "https://example.com/short.png",
brandColor: "#fff",
category: CATEGORY.partner,
},
{
id: "alpha",
apiName: "alpha",
displayName: "Alpha",
description: "",
iconUri: "https://example.com/alpha.png",
brandColor: "#5059c980",
category: CATEGORY.partner,
},
], {
filter: "",
category: "all",
source: "",
config: { subscriptionId: "sub", gatewayName: "ns", resourceGroup: "rg" },
});
assert.match(html, /style="background:#5059c922"/, "valid hex colors should render as the icon background");
assert.doesNotMatch(html, /background-image/, "non-hex color payloads must not reach inline CSS");
assert.doesNotMatch(html, /attacker\.example/, "attacker-controlled CSS URLs must not render");
assert.doesNotMatch(html, /background:#fff22/, "shorthand colors cannot be combined with an appended alpha");
assert.doesNotMatch(html, /background:#5059c98022/, "colors that already contain alpha cannot append another alpha");
});
test("section heads are accessible toggle buttons", () => {
const html = catalogHtmlFull();
assert.match(
html,
/<button class="section-head" type="button" aria-expanded="(true|false)" aria-controls="grid-mine"/,
"each section head must be a button wired with aria-expanded + aria-controls",
);
assert.match(html, /aria-controls="grid-microsoft"/, "microsoft head controls its grid");
assert.match(html, /aria-controls="grid-partner"/, "partner head controls its grid");
});
test("the Added (N) filter pill is gone", () => {
const html = catalogHtmlFull();
assert.doesNotMatch(html, /id="filter-bar"/, "the old filter bar must be removed");
assert.doesNotMatch(html, /Added \(/, "the old Added (N) pill copy must be gone");
});
test("the catalog header shows the active namespace and a switch-namespace button", () => {
// The header surfaces the active connector namespace in the sub line, and a
// "switch namespace" button in the gw-actions row is the switch affordance:
// clicking it returns to the /setup picker. So the page must show the
// namespace name and carry the nav to /setup.
const html = catalogHtml(); // fixture config.gatewayName = "ns"
assert.match(html, /class="cn-name">ns</, "the header must show the active namespace name");
assert.match(html, /id="switch-ns"/, "the header must render the switch-namespace button");
assert.match(
html,
/id="switch-ns"[^>]*onclick="[^"]*window\.location\.href='\/setup'/,
"clicking switch namespace must navigate to the /setup picker",
);
});
test("My MCP hydration adds a per-server Sandbox deep link", () => {
const html = catalogHtmlFull();
assert.match(
html,
/data-sandbox-url="https:\/\/connectors\.azure\.com\/sub\/rg\/ns\/mcp-playground\?server=azureblob"/,
"each connector tile must carry its namespace Sandbox URL",
);
assert.match(html, /sandbox\.className = "item-add sandbox-btn item-icon-action"/, "installed My MCPs must get the Sandbox action");
assert.match(html, /sandbox\.title = "Open this MCP in Connector Namespace playground"/, "the Sandbox icon should describe the Connector Namespace playground");
assert.match(html, /sandbox\.setAttribute\("aria-label", "Open " \+ displayName \+ " in Connector Sandbox"\)/, "the icon-only action needs an accessible label");
assert.doesNotMatch(html, /<span>Sandbox<\/span>/, "the compact action should show only the flask mark");
assert.ok(
html.indexOf('if (!st || !st.installed)') < html.indexOf('sandbox.className = "item-add sandbox-btn item-icon-action"'),
"the Sandbox action must be created only after the non-installed tile returns",
);
});
test("connect is compact and local disconnect appears only for local entries", () => {
const html = catalogHtmlFull();
const css = baseStyles();
assert.match(css, /\.item-add\.primary\s*\{[^}]*min-width:\s*62px;[^}]*font-size:\s*\.72rem;/, "Connect should use the compact primary-button sizing");
assert.match(html, /connectIcon \+ "<span>Connect<\/span>"/, "Connect should include the plug mark before its label");
assert.match(html, /remove\.className = "item-add split-main item-icon-action"/, "local disconnect should be compact");
assert.match(html, /remove\.setAttribute\("aria-label", "Disconnect " \+ displayName \+ " from Copilot"\)/, "the icon needs a connector-specific label");
assert.match(html, /const disconnectIcon = '[^']*m2 2 12 12/, "the visible control should use a slashed plug mark");
assert.match(html, /remove\.innerHTML = disconnectIcon/, "the visible control should use the disconnect mark");
assert.match(html, /if \(st\.inCli\) \{\s*remove = document\.createElement\("button"\)/, "remote-only resources must not offer local disconnect");
assert.match(html, /if \(remove\) splitWrap\.appendChild\(remove\)/, "namespace delete options must remain available without a disconnect button");
assert.match(html, /\.split-remove \.split-main \{[^}]*color:var\(--danger\)/, "the disconnect icon should be red");
assert.match(html, /\.split-remove \.split-caret\s*\{[^}]*padding:\.2rem \.3rem/, "the destructive-options caret should stay narrow");
assert.match(html, /\.split-remove \.split-caret svg\s*\{[^}]*width:8px; height:8px;/, "the caret mark should match its smaller button");
});
test("My MCPs sorts fully connected entries before other installed resources", () => {
const html = catalogHtmlFull();
assert.match(
html,
/item\.dataset\.connectionReady = st\.connectionStatus === "Connected" && st\.inCli \? "1" : "0"/,
"hydration should mark resources that are connected and available in Copilot",
);
assert.match(
html,
/if \(grid\.id === "grid-mine"\)[\s\S]*Number\(b\.dataset\.connectionReady === "1"\) - Number\(a\.dataset\.connectionReady === "1"\)/,
"My MCPs should put fully connected entries first",
);
});
test("narrow connector cards keep the name above wrapped actions", () => {
const css = baseStyles();
assert.match(css, /@media \(max-width: 520px\)/, "catalog cards need a narrow-panel layout");
assert.match(
css,
/\.item\s*\{[^}]*grid-template-columns:\s*40px minmax\(0,\s*1fr\)/,
"narrow cards must preserve a real text column beside the icon",
);
assert.match(
css,
/\.item > \.item-add,\s*\.item > \.item-actions\s*\{[^}]*grid-row:\s*2/,
"actions must move below the connector name instead of squeezing it out",
);
assert.match(css, /\.item-actions\s*\{[^}]*flex-wrap:\s*wrap/, "narrow action rows must wrap");
});
@@ -0,0 +1,139 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises";
import { delimiter, join } from "node:path";
import { tmpdir } from "node:os";
import { armSegment, parseAzureCliToken, resolvePosixAzureCli, waitForProvisioning } from "./armClient.mjs";
const here = new URL(".", import.meta.url);
test("ARM path segments reject traversal aliases", () => {
assert.throws(() => armSegment("."), /Invalid ARM resource identifier/);
assert.throws(() => armSegment(".."), /Invalid ARM resource identifier/);
assert.equal(armSegment("valid.name"), "valid.name");
});
test("namespace creation is create-only and reports provisioning timeout", async () => {
const source = await readFile(new URL("armClient.mjs", here), "utf8");
const createResourceGroup = source.slice(
source.indexOf("export async function createResourceGroup"),
source.indexOf("export async function listUserAssignedIdentities"),
);
const createConnectorGateway = source.slice(
source.indexOf("export async function createConnectorGateway"),
);
assert.match(createResourceGroup, /"If-None-Match": "\*"/);
assert.match(createConnectorGateway, /"If-None-Match": "\*"/);
assert.match(source, /Provisioning timed out/);
});
test("namespace creation polls an empty 202 result until explicit success", async () => {
const states = [
{ properties: { provisioningState: "InProgress" } },
{ properties: { provisioningState: "Succeeded" } },
];
let calls = 0;
const result = await waitForProvisioning(
undefined,
"gateway",
async () => {
calls++;
return states.shift();
},
{ maxPolls: 2, delay: async () => {} },
);
assert.equal(calls, 2);
assert.equal(result.properties.provisioningState, "Succeeded");
await assert.rejects(
waitForProvisioning(undefined, "gateway", async () => undefined, { maxPolls: 1, delay: async () => {} }),
/last state: unknown/,
);
});
test("Azure authentication is brokered by Azure CLI", async () => {
const source = await readFile(new URL("armClient.mjs", here), "utf8");
assert.match(source, /account get-access-token --resource/);
assert.doesNotMatch(source, /04b07795-8ddb-461a-bbee-02f9e1bf7b46/);
assert.doesNotMatch(source, /refreshToken/);
assert.match(source, /await fs\.unlink\(LEGACY_AUTH_CACHE\)/);
assert.match(source, /resolveWindowsAzureCli/);
assert.match(source, /resolvePosixAzureCli/);
assert.match(source, /fs\.realpath\(path\)/);
assert.match(source, /windowsSystemExecutable\("cmd\.exe"\)/);
assert.match(source, /\{ cwd: homedir\(\), encoding: "utf8"/);
assert.deepEqual(
parseAzureCliToken(JSON.stringify({ accessToken: "token", expires_on: 2_000_000_000 })),
{ token: "token", expiresAt: 2_000_000_000_000 },
);
assert.throws(() => parseAzureCliToken("{}"), /incomplete ARM token/);
});
test("POSIX Azure CLI resolution rejects workspace-controlled binaries", async () => {
const root = await mkdtemp(join(tmpdir(), "cn-az-path-"));
const workspace = join(root, "workspace");
const workspaceBin = join(workspace, "node_modules", ".bin");
const trustedBin = join(root, "trusted-bin");
await Promise.all([
mkdir(workspaceBin, { recursive: true }),
mkdir(trustedBin, { recursive: true }),
]);
await Promise.all([
writeFile(join(workspaceBin, "az"), "workspace", { mode: 0o755 }),
writeFile(join(trustedBin, "az"), "trusted", { mode: 0o755 }),
]);
try {
const resolved = await resolvePosixAzureCli(
[workspaceBin, trustedBin].join(delimiter),
workspace,
);
assert.equal(resolved, await realpath(join(trustedBin, "az")));
await assert.rejects(
resolvePosixAzureCli(workspaceBin, workspace),
/outside the current workspace/,
);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("installer preserves capability tokens and persists direct HTTP entries", async () => {
const source = await readFile(new URL("install.mjs", here), "utf8");
const fallbacks = source.match(/installConnector\(config, apiName, displayName, callbackBase, scope, capabilityToken\)/g);
assert.equal(fallbacks?.length, 1);
assert.match(source, /reauthConnectorWithAttempts\([\s\S]*?capabilityToken,[\s\S]*?attemptedConfigNames/);
assert.match(source, /headers: \{ "X-API-Key": key \}/);
assert.match(source, /const cacheKey = `\$\{sub\}:\$\{location\}:\$\{apiName\}:\$\{requireSwagger\}`/);
assert.match(source, /throwAfterCleanup\(error, \[\(\) => deleteConnection\(config, connName\)\]\)/);
assert.match(source, /freshConnection: true/);
assert.match(source, /freshConnection: false/);
assert.match(source, /await fs\.open\(lockPath, "wx", 0o600\)/);
assert.match(source, /await fs\.rename\(temporary, path\)/);
assert.match(source, /resolveSystemExecutable\("rundll32\.exe"\)/);
});
test("smoke cleanup runs from finally and reports cleanup failures", async () => {
const source = await readFile(new URL("test/smoke.mjs", here), "utf8");
assert.match(source, /finally \{\s*if \(record\.cleanup\)/);
assert.match(source, /record\.cleanupError/);
assert.match(source, /failed\.length > 0 \|\| orchestrationErrors\.length > 0/);
assert.match(source, /probe\.status === "passed"/);
assert.match(source, /probe\.status === "skipped"/);
assert.match(source, /mode: 0o700/);
assert.match(source, /mode: 0o600/);
assert.match(source, /chmodSync\(PENDING_FILE, 0o600\)/);
});
test("test reports do not persist successful tool response content", async () => {
const source = await readFile(new URL("test/mcp-probe.mjs", here), "utf8");
assert.doesNotMatch(source, /toolsCall\.preview\s*=/);
assert.match(source, /toolsCall\.result = "response received"/);
});
test("obsolete in-memory add and remove canvas actions are not advertised", async () => {
const source = await readFile(new URL("extension.mjs", here), "utf8");
assert.doesNotMatch(source, /name: "add_connector"/);
assert.doesNotMatch(source, /name: "remove_connector"/);
assert.doesNotMatch(source, /name: "list_connectors"/);
assert.match(source, /name: "open_sandbox"/);
});
@@ -0,0 +1,49 @@
const SANDBOX_ORIGIN = "https://connectors.azure.com";
function requiredString(value, name) {
const text = String(value || "").trim();
if (!text) throw new Error(`missing ${name}`);
return text;
}
export function buildSandboxUrl(config, server) {
const subscriptionId = requiredString(config?.subscriptionId, "subscriptionId");
const resourceGroup = requiredString(config?.resourceGroup, "resourceGroup");
const gatewayName = requiredString(config?.gatewayName, "gatewayName");
const serverName = requiredString(server, "server");
const path = [subscriptionId, resourceGroup, gatewayName, "mcp-playground"]
.map(encodeURIComponent)
.join("/");
const url = new URL(`/${path}`, SANDBOX_ORIGIN);
url.searchParams.set("server", serverName);
return url.toString();
}
export function resolveSandboxConnector(catalog, installedState, query) {
const requested = requiredString(query, "server").toLowerCase();
const available = catalog
.filter((connector) => installedState[connector.apiName]?.installed)
.map((connector) => ({
id: connector.apiName,
displayName: connector.displayName,
}));
const exact = available.filter((connector) =>
connector.id.toLowerCase() === requested ||
connector.displayName.toLowerCase() === requested
);
if (exact.length === 1) return { connector: exact[0], available };
const partial = available.filter((connector) =>
connector.id.toLowerCase().includes(requested) ||
connector.displayName.toLowerCase().includes(requested)
);
if (partial.length === 1) return { connector: partial[0], available };
return {
connector: null,
reason: partial.length > 1 ? "ambiguous" : "not_found_in_my_mcps",
matches: partial,
available,
};
}
@@ -0,0 +1,54 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { buildSandboxUrl, resolveSandboxConnector } from "./sandbox.mjs";
const config = {
subscriptionId: "f34b22a3-2202-4fb1-b040-1332bd928c84",
resourceGroup: "jack sandboxgroup rg",
gatewayName: "yeah-github-cli",
};
const catalog = [
{ apiName: "WorkIQTeamsMCP-1a81f9", displayName: "Work IQ Teams MCP" },
{ apiName: "WorkIQSharePointMCP-abcd", displayName: "Work IQ SharePoint MCP" },
{ apiName: "OtherMCP-1234", displayName: "Other MCP" },
];
const installedState = {
"WorkIQTeamsMCP-1a81f9": { installed: true },
"WorkIQSharePointMCP-abcd": { installed: true },
"OtherMCP-1234": { installed: false },
};
test("buildSandboxUrl creates the namespace playground deep link", () => {
assert.equal(
buildSandboxUrl(config, "WorkIQTeamsMCP-1a81f9"),
"https://connectors.azure.com/f34b22a3-2202-4fb1-b040-1332bd928c84/jack%20sandboxgroup%20rg/yeah-github-cli/mcp-playground?server=WorkIQTeamsMCP-1a81f9",
);
});
test("resolveSandboxConnector finds a My MCP by display-name fragment", () => {
const result = resolveSandboxConnector(catalog, installedState, "teams");
assert.deepEqual(result.connector, {
id: "WorkIQTeamsMCP-1a81f9",
displayName: "Work IQ Teams MCP",
});
});
test("resolveSandboxConnector never returns catalog entries outside My MCPs", () => {
const result = resolveSandboxConnector(catalog, installedState, "Other MCP");
assert.equal(result.connector, null);
assert.equal(result.reason, "not_found_in_my_mcps");
assert.deepEqual(result.available.map((connector) => connector.id), [
"WorkIQTeamsMCP-1a81f9",
"WorkIQSharePointMCP-abcd",
]);
});
test("resolveSandboxConnector reports ambiguous names with matches", () => {
const result = resolveSandboxConnector(catalog, installedState, "work iq");
assert.equal(result.connector, null);
assert.equal(result.reason, "ambiguous");
assert.equal(result.matches.length, 2);
});
+616
View File
@@ -0,0 +1,616 @@
// Loopback HTTP server — serves connector namespace picker + connector catalog UI.
import { createServer } from "node:http";
import { randomBytes } from "node:crypto";
import { renderCatalogHtml, renderErrorHtml, renderSetupHtml } from "./renderer.mjs";
import { renderCreateNamespaceHtml } from "./createPage.mjs";
import { addConnector, removeConnector, saveConfig, clearConfig } from "./state.mjs";
import { fetchCatalog, invalidateCache } from "./catalog.mjs";
import {
listConnectorGateways,
listSubscriptions,
listResourceGroups,
listUserAssignedIdentities,
checkConnectorGatewayNameAvailable,
createResourceGroup,
createConnectorGateway,
buildGatewayIdentity,
} from "./armClient.mjs";
import { installConnector, finishInstall, reauthConnector, finishReauth, openInBrowser, openMcpConfigFile, getInstalledState, uninstallConnector, removeLocalEntry, deleteConnection, prewarmMeta } from "./install.mjs";
const servers = new Map();
const starting = new Map(); // instanceId → Promise<entry> while a server is binding
const gatewayCache = new Map();
const pendingOAuth = new Map(); // connName → timestamp
const PENDING_OAUTH_TTL_MS = 15 * 60 * 1000;
const MUTATION_REPLAY_TTL_MS = 15 * 60 * 1000;
const CAPABILITY_TOKEN_HEADER = "x-connector-namespace-token";
const MAX_REQUEST_BODY_BYTES = 64 * 1024;
const REQUEST_ID = /^[0-9a-f]{32}$/;
class RequestBodyTooLargeError extends Error {}
function createCapabilityToken() {
return randomBytes(32).toString("base64url");
}
export function isCanonicalHost(req, canonicalHost) {
return String(req.headers.host || "").toLowerCase() === String(canonicalHost || "").toLowerCase();
}
export function requiresCapabilityToken(pathname) {
return pathname.startsWith("/api/") || pathname === "/oauth-status" || pathname.startsWith("/auth/callback/");
}
export function hasCapabilityToken(req, url, expectedToken) {
if (!expectedToken) {
return false;
}
const header = req.headers[CAPABILITY_TOKEN_HEADER];
const headerToken = Array.isArray(header) ? header[0] : header;
const callbackToken = url.pathname.startsWith("/auth/callback/")
? url.searchParams.get("cn_token")
: null;
return headerToken === expectedToken || callbackToken === expectedToken;
}
function rejectForbidden(res, error) {
res.statusCode = 403;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ error }));
}
// Drop stale/abandoned consent markers so the map can't grow unbounded and a
// brand-new install of the same connection can't observe a stale "done".
function prunePendingOAuth() {
const now = Date.now();
for (const [name, ts] of pendingOAuth) {
if (now - ts > PENDING_OAUTH_TTL_MS) pendingOAuth.delete(name);
}
}
export function runIdempotentOperation(operations, key, start, now = Date.now()) {
for (const [id, operation] of operations) {
if (operation.expiresAt <= now) operations.delete(id);
}
const existing = operations.get(key);
if (existing) return existing.promise;
const promise = Promise.resolve().then(start);
operations.set(key, { promise, expiresAt: now + MUTATION_REPLAY_TTL_MS });
return promise;
}
// Whether a connector was added during the life of THIS extension process.
// MCP tools are only loaded by the CLI at session start, so an install done
// after the process started isn't usable until the session restarts. A real
// session restart spawns a fresh process and resets this to false. `acked`
// lets the user dismiss the reminder for the rest of the process.
let pendingRestart = false;
let restartAcked = false;
export function parseBody(req) {
return new Promise((resolve, reject) => {
let data = "";
let size = 0;
let settled = false;
req.on("data", (chunk) => {
if (settled) return;
size += Buffer.byteLength(chunk);
if (size > MAX_REQUEST_BODY_BYTES) {
settled = true;
data = "";
reject(new RequestBodyTooLargeError());
return;
}
data += chunk;
});
req.on("end", () => {
if (settled) return;
settled = true;
try { resolve(JSON.parse(data)); }
catch { resolve({}); }
});
});
}
// Blocks cross-site POSTs to /api/* so a random web page can't drive the user's
// ARM operations through this loopback server (CSRF). Returns true only when the
// request carries an explicit foreign-origin signal: an Origin header from a
// different http(s) origin than the canonical loopback URL, an opaque `null`
// origin (sandboxed iframe / data: or blob: URI), or a Fetch-Metadata marker of
// cross-site/same-site. The panel loads as a top-level http document, so its own
// fetches are same-origin (Origin === our canonical loopback origin, never
// "null"), and header-less callers (the node test harness, non-browser clients)
// fall through as allowed, so nothing legit breaks.
export function isCrossSiteRequest(req, canonicalOrigin) {
const origin = req.headers.origin;
if (origin) {
if (origin === canonicalOrigin) return false; // our own loopback UI
if (origin === "null") return true; // opaque origin: sandboxed iframe / data: or blob: URI
if (/^https?:\/\//i.test(origin)) return true; // a different web page
return false; // non-web scheme (host webview)
}
const site = req.headers["sec-fetch-site"];
return site === "cross-site" || site === "same-site";
}
async function handleRequest(req, res, instanceId, serverEntry) {
const url = new URL(req.url, "http://localhost");
if (!isCanonicalHost(req, serverEntry.host)) {
rejectForbidden(res, "host_not_allowed");
return;
}
if (requiresCapabilityToken(url.pathname) && !hasCapabilityToken(req, url, serverEntry.token)) {
rejectForbidden(res, "missing_or_invalid_capability_token");
return;
}
// Reject cross-site attempts to invoke state-changing API routes (CSRF).
// Only POST /api/* is gated: GET navigations like the OAuth callback and the
// read routes are never blocked here.
if (req.method === "POST" && url.pathname.startsWith("/api/") && isCrossSiteRequest(req, serverEntry.origin)) {
rejectForbidden(res, "cross_site_blocked");
return;
}
// --- API routes ---
if (req.method === "POST" && url.pathname === "/api/add") {
const body = await parseBody(req);
const config = serverEntry.config;
if (!config) { json(res, { added: false, reason: "no_config" }); return; }
const catalog = await fetchCatalog(config.subscriptionId, config.resourceGroup, config.gatewayName);
const connector = catalog.find((c) => c.id === body.id);
json(res, connector ? addConnector(connector) : { added: false, reason: "not_found" });
return;
}
if (req.method === "POST" && url.pathname === "/api/remove") {
const body = await parseBody(req);
json(res, removeConnector(body.id));
return;
}
if (req.method === "POST" && url.pathname === "/api/select-gateway") {
const body = await parseBody(req);
const { subscriptionId, resourceGroup, gatewayName } = body;
if (!subscriptionId || !resourceGroup || !gatewayName) {
json(res, { error: "missing_fields" });
return;
}
const config = { subscriptionId, resourceGroup, gatewayName };
serverEntry.config = config;
saveConfig(config);
invalidateCache();
json(res, { ok: true });
return;
}
if (req.method === "POST" && url.pathname === "/api/change-gateway") {
try {
clearConfig();
serverEntry.config = null;
invalidateCache();
json(res, { ok: true });
} catch (err) {
json(res, { error: err.message });
}
return;
}
if (req.method === "GET" && url.pathname === "/api/gateways") {
const subId = url.searchParams.get("subscriptionId");
const all = url.searchParams.get("all") === "true";
if (!subId) { json(res, { error: "missing subscriptionId" }); return; }
const cacheKey = `gw:${subId}:${all ? "all" : "top"}`;
if (gatewayCache.has(cacheKey)) {
const cached = gatewayCache.get(cacheKey);
json(res, { gateways: cached.items, hasMore: cached.hasMore, cached: true });
return;
}
try {
const result = await listConnectorGateways(subId, { fetchAll: all });
gatewayCache.set(cacheKey, result);
json(res, { gateways: result.items, hasMore: result.hasMore });
} catch (err) {
json(res, { error: err.message });
}
return;
}
// --- Create connector namespace routes ---
if (req.method === "GET" && url.pathname === "/api/resource-groups") {
const subId = url.searchParams.get("subscriptionId");
if (!subId) { json(res, { error: "missing subscriptionId" }); return; }
try {
json(res, { resourceGroups: await listResourceGroups(subId) });
} catch (err) {
json(res, { error: err.message });
}
return;
}
if (req.method === "GET" && url.pathname === "/api/identities") {
const subId = url.searchParams.get("subscriptionId");
if (!subId) { json(res, { error: "missing subscriptionId" }); return; }
try {
json(res, { identities: await listUserAssignedIdentities(subId) });
} catch (err) {
json(res, { error: err.message });
}
return;
}
if (req.method === "GET" && url.pathname === "/api/check-name") {
const subId = url.searchParams.get("subscriptionId");
const resourceGroup = url.searchParams.get("resourceGroup");
const name = url.searchParams.get("name");
if (!subId || !resourceGroup || !name) { json(res, { error: "missing_fields" }); return; }
try {
json(res, { available: await checkConnectorGatewayNameAvailable(subId, resourceGroup, name) });
} catch (err) {
json(res, { error: err.message });
}
return;
}
if (req.method === "POST" && url.pathname === "/api/create-namespace") {
const body = await parseBody(req);
const { subscriptionId, resourceGroup, createNewResourceGroup, region, name, enableSystemIdentity, userAssignedIds } = body;
if (!subscriptionId || !resourceGroup || !region || !name) {
json(res, { error: "missing_fields" });
return;
}
try {
if (createNewResourceGroup) {
await createResourceGroup(subscriptionId, resourceGroup, region);
}
const identity = buildGatewayIdentity(!!enableSystemIdentity, Array.isArray(userAssignedIds) ? userAssignedIds : []);
await createConnectorGateway(subscriptionId, resourceGroup, name, { location: region, identity });
const config = { subscriptionId, resourceGroup, gatewayName: name };
serverEntry.config = config;
saveConfig(config);
invalidateCache();
gatewayCache.clear();
json(res, { ok: true });
} catch (err) {
json(res, { error: err.message });
}
return;
}
// --- Install flow routes ---
if (req.method === "POST" && url.pathname === "/api/install") {
const body = await parseBody(req);
const config = serverEntry.config;
if (!config) { json(res, { error: "no_config" }); return; }
const { apiName, displayName, requestId } = body;
if (!apiName) { json(res, { error: "missing apiName" }); return; }
if (!REQUEST_ID.test(requestId || "")) { json(res, { error: "invalid requestId" }); return; }
const port = new URL(serverEntry.url).port;
const callbackBase = `http://127.0.0.1:${port}/auth/callback/`;
try {
const result = await runIdempotentOperation(
serverEntry.operations,
`install:${requestId}`,
() => installConnector(config, apiName, displayName || apiName, callbackBase, "profile", serverEntry.token),
);
if (result && !result.error && !result.needsConsent) { pendingRestart = true; restartAcked = false; }
json(res, result);
} catch (err) {
json(res, { error: err.message });
}
return;
}
if (req.method === "POST" && url.pathname === "/api/reauth") {
const body = await parseBody(req);
const config = serverEntry.config;
if (!config) { json(res, { error: "no_config" }); return; }
const { apiName, displayName, requestId } = body;
if (!apiName) { json(res, { error: "missing apiName" }); return; }
if (!REQUEST_ID.test(requestId || "")) { json(res, { error: "invalid requestId" }); return; }
const port = new URL(serverEntry.url).port;
const callbackBase = `http://127.0.0.1:${port}/auth/callback/`;
try {
const result = await runIdempotentOperation(
serverEntry.operations,
`reauth:${requestId}`,
() => reauthConnector(config, apiName, displayName || apiName, callbackBase, "profile", serverEntry.token),
);
if (result && !result.error && !result.needsConsent) { pendingRestart = true; restartAcked = false; }
json(res, result);
} catch (err) {
json(res, { error: err.message });
}
return;
}
if (req.method === "POST" && url.pathname === "/api/finish-install") {
const body = await parseBody(req);
const config = serverEntry.config;
if (!config) { json(res, { error: "no_config" }); return; }
if (!body.apiName) { json(res, { error: "missing apiName" }); return; }
if (!body.connName) { json(res, { error: "missing connName" }); return; }
try {
const result = await finishInstall(config, body.apiName, body.displayName, body.connName, body.location, "profile");
if (result && !result.error) { pendingRestart = true; restartAcked = false; }
json(res, result);
} catch (err) {
json(res, { error: err.message });
}
return;
}
if (req.method === "POST" && url.pathname === "/api/finish-reauth") {
const body = await parseBody(req);
const config = serverEntry.config;
if (!config) { json(res, { error: "no_config" }); return; }
if (!body.apiName) { json(res, { error: "missing apiName" }); return; }
if (!body.connName) { json(res, { error: "missing connName" }); return; }
try {
// configName may be absent when reauth fell back to a fresh install
// (stored connection was gone); finishReauth handles that defensively.
const result = await finishReauth(config, body.apiName, body.displayName, body.connName, body.configName, body.location, "profile");
if (result && !result.error) { pendingRestart = true; restartAcked = false; }
json(res, result);
} catch (err) {
json(res, { error: err.message });
}
return;
}
if (req.method === "POST" && url.pathname === "/api/open-url") {
const body = await parseBody(req);
if (!body.url || !/^https?:\/\//.test(body.url)) { json(res, { error: "invalid url" }); return; }
try {
await openInBrowser(body.url);
json(res, { ok: true });
} catch (err) {
json(res, { error: err.message });
}
return;
}
if (req.method === "POST" && url.pathname === "/api/open-config") {
try {
const result = await openMcpConfigFile();
json(res, result);
} catch (err) {
json(res, { ok: false, error: err.message });
}
return;
}
if (req.method === "GET" && url.pathname === "/oauth-status") {
prunePendingOAuth();
const connName = url.searchParams.get("connectionName") || "";
// Consume the marker once observed so the map self-cleans on the happy
// path instead of lingering until the TTL sweep. delete() returns true
// iff the marker existed, which is exactly the "done" signal.
const done = connName ? pendingOAuth.delete(connName) : false;
json(res, { done });
return;
}
if (req.method === "GET" && url.pathname === "/api/state") {
const config = serverEntry.config;
if (!config) { json(res, { error: "no_config" }); return; }
try {
const state = await getInstalledState(config);
json(res, { state, pendingRestart: pendingRestart && !restartAcked });
} catch (err) {
json(res, { error: err.message });
}
return;
}
if (req.method === "POST" && url.pathname === "/api/ack-restart") {
restartAcked = true;
json(res, { ok: true });
return;
}
if (req.method === "POST" && url.pathname === "/api/uninstall") {
const body = await parseBody(req);
const config = serverEntry.config;
if (!config) { json(res, { error: "no_config" }); return; }
if (!body.apiName) { json(res, { error: "missing apiName" }); return; }
try {
const result = await uninstallConnector(config, body.apiName);
json(res, result);
} catch (err) {
json(res, { error: err.message });
}
return;
}
// Local-only remove: unwire the connector from Copilot's mcp config without
// deleting anything on the namespace. Mirrors /api/uninstall but calls the
// local-only primitive.
if (req.method === "POST" && url.pathname === "/api/remove-local") {
const body = await parseBody(req);
const config = serverEntry.config;
if (!config) { json(res, { error: "no_config" }); return; }
if (!body.apiName) { json(res, { error: "missing apiName" }); return; }
try {
const result = await removeLocalEntry(config, body.apiName);
json(res, result);
} catch (err) {
json(res, { error: err.message });
}
return;
}
// Roll back a connection orphaned by a cancelled install (no config exists
// yet, so /api/uninstall can't find it — delete the connection directly).
if (req.method === "POST" && url.pathname === "/api/rollback-connection") {
const body = await parseBody(req);
const config = serverEntry.config;
if (!config) { json(res, { error: "no_config" }); return; }
if (!body.connName) { json(res, { error: "missing connName" }); return; }
try {
const result = await deleteConnection(config, body.connName);
json(res, result);
} catch (err) {
json(res, { error: err.message });
}
return;
}
if (req.method === "GET" && url.pathname.startsWith("/auth/callback/")) {
const connName = decodeURIComponent(url.pathname.slice("/auth/callback/".length));
prunePendingOAuth();
if (connName) pendingOAuth.set(connName, Date.now());
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(`<!doctype html><html><head><meta charset="utf-8"><title>Sign-in complete</title></head><body style="font-family:system-ui;padding:2rem;"><h2>Sign-in complete</h2><p>You can close this tab and return to Copilot.</p></body></html>`);
return;
}
// --- Page routes ---
const config = serverEntry.config;
// Create connector namespace page (reachable with or without a saved config)
if (url.pathname === "/create") {
try {
const subs = await listSubscriptions();
const preselected = url.searchParams.get("subscriptionId") || "";
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(renderCreateNamespaceHtml(subs, preselected, serverEntry.token));
} catch (err) {
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(renderErrorHtml(`Failed to load subscriptions. Run az login in a terminal, then reload this page.\n\n${err.message}`));
}
return;
}
// Setup page (no gateway configured)
if (!config || url.pathname === "/setup") {
try {
const subs = await listSubscriptions();
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(renderSetupHtml(subs, "", serverEntry.token));
} catch (err) {
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(renderErrorHtml(`Failed to load subscriptions. Run az login in a terminal, then reload this page.\n\n${err.message}`));
}
return;
}
// Catalog page
const filter = url.searchParams.get("filter") || "";
const category = url.searchParams.get("category") || "";
const source = url.searchParams.get("source") || "";
try {
const catalog = await fetchCatalog(config.subscriptionId, config.resourceGroup, config.gatewayName);
// Warm connector metadata (opId + connection params) in the background so
// the Connect click doesn't pay for the slow swagger export.
prewarmMeta(config, catalog.map((c) => c.apiName));
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(renderCatalogHtml(instanceId, catalog, { filter, category, source, config }, serverEntry.token));
} catch (err) {
// Trust-and-fallback: a saved namespace that can no longer be read
// (deleted, access revoked, a transient outage) should drop the user
// back to the picker, not a dead-end error page. Only if even the
// picker can't load its subscriptions do we surface the raw error.
try {
const subs = await listSubscriptions();
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(renderSetupHtml(subs, `couldn't open namespace ${config.gatewayName} .. pick another to continue.`, serverEntry.token));
} catch (subErr) {
// Both the namespace catalog and the subscription list failed. Surface
// the subscription error (the reason the picker itself can't render) and
// keep the original namespace error for context.
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(renderErrorHtml(`couldn't load subscriptions: ${subErr.message} .. opening namespace ${config.gatewayName} also failed: ${err.message}`));
}
}
}
function json(res, data) {
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify(data));
}
export function getServerConfig(instanceId) {
return servers.get(instanceId)?.config ?? null;
}
export function listenOnLoopback(server) {
return new Promise((resolve, reject) => {
const onError = (error) => reject(error);
server.once("error", onError);
server.listen(0, "127.0.0.1", () => {
server.removeListener("error", onError);
resolve();
});
});
}
export function startServer(instanceId, { config, defaultConfig } = {}) {
const existing = servers.get(instanceId);
if (existing) {
if (config !== undefined) existing.config = config;
return Promise.resolve(existing);
}
const inflight = starting.get(instanceId);
if (inflight) return inflight;
const p = (async () => {
const entry = {
server: null,
url: "",
host: "",
origin: "",
token: createCapabilityToken(),
config: config ?? defaultConfig ?? null,
operations: new Map(),
};
const server = createServer((req, res) => {
handleRequest(req, res, instanceId, entry).catch((err) => {
if (res.writableEnded) return;
res.statusCode = err instanceof RequestBodyTooLargeError ? 413 : 500;
if (!(err instanceof RequestBodyTooLargeError)) {
console.error("[connector-namespaces] request failed:", err);
}
json(res, { error: err instanceof RequestBodyTooLargeError ? "request_body_too_large" : "internal_error" });
});
});
entry.server = server;
await listenOnLoopback(server);
const address = server.address();
const port = typeof address === "object" && address ? address.port : 0;
entry.host = `127.0.0.1:${port}`;
entry.origin = `http://${entry.host}`;
entry.url = `${entry.origin}/`;
servers.set(instanceId, entry);
return entry;
})();
// Record the in-flight start synchronously so a concurrent open() for the
// same instance awaits this server instead of binding a second one and
// leaking the first.
starting.set(instanceId, p);
const clearStarting = () => { if (starting.get(instanceId) === p) starting.delete(instanceId); };
p.then(clearStarting, clearStarting);
return p;
}
export async function stopServer(instanceId) {
const inflight = starting.get(instanceId);
if (inflight) { try { await inflight; } catch { /* start failed; nothing to close */ } }
const entry = servers.get(instanceId);
if (entry) {
servers.delete(instanceId);
// close() never resolves while the iframe holds a keep-alive socket —
// drop live connections first so onClose can't hang and leak the process.
if (typeof entry.server.closeAllConnections === "function") entry.server.closeAllConnections();
await new Promise((resolve) => entry.server.close(() => resolve()));
}
}
@@ -0,0 +1,215 @@
// Guards for the cross-site request gate on the loopback API server.
//
// Run: node --test extensions/connector-namespaces/server.test.mjs
//
// The server binds an ephemeral 127.0.0.1 port and JSON-parses every POST body,
// so without a check any web page the user visits could script-drive their ARM
// operations (CSRF). isCrossSiteRequest is the gate: it blocks a POST /api/*
// only when the request carries an explicit foreign-origin signal, and lets the
// panel's own same-origin fetches — and header-less callers like this test
// harness — through untouched. Importing server.mjs has no side effects at eval;
// the HTTP server only starts when startServer() is called.
import { test } from "node:test";
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import { Readable } from "node:stream";
import {
getServerConfig,
hasCapabilityToken,
isCanonicalHost,
isCrossSiteRequest,
listenOnLoopback,
parseBody,
requiresCapabilityToken,
runIdempotentOperation,
startServer,
stopServer,
} from "./server.mjs";
import { isValidConfig } from "./state.mjs";
// Minimal request stub: only headers matter to the gate.
function req(headers) {
return { headers };
}
test("same-origin Origin (our own loopback UI) is allowed", () => {
const r = req({ host: "127.0.0.1:54321", origin: "http://127.0.0.1:54321" });
assert.equal(isCrossSiteRequest(r, "http://127.0.0.1:54321"), false);
});
test("no headers at all (test harness / non-browser client) is allowed", () => {
assert.equal(isCrossSiteRequest(req({}), "http://127.0.0.1:54321"), false);
});
test("non-web-scheme Origin (host webview) is allowed", () => {
// Some app webviews send Origin like "vscode-webview://..." or "app://..."
// custom schemes. Those aren't a browsable web page driving a CSRF, so we
// don't block them; only http(s) foreign origins and opaque `null` origins
// are treated as hostile.
const r = req({ host: "127.0.0.1:54321", origin: "app://obsidian.md" });
assert.equal(isCrossSiteRequest(r, "http://127.0.0.1:54321"), false);
});
test("opaque null Origin (sandboxed iframe / data: URI) is blocked", () => {
// Browsers send the literal string "null" as Origin from sandboxed iframes
// (<iframe sandbox="allow-scripts">), data:/blob: documents, and some
// cross-origin redirect chains. That's exactly the opaque context a CSRF
// attacker scripts from, and never our real top-level http panel (which
// sends Origin: http://<host>), so we treat it as hostile.
const r = req({ host: "127.0.0.1:54321", origin: "null" });
assert.equal(isCrossSiteRequest(r, "http://127.0.0.1:54321"), true);
});
test("foreign https Origin (a real web page) is blocked", () => {
const r = req({ host: "127.0.0.1:54321", origin: "https://evil.example.com" });
assert.equal(isCrossSiteRequest(r, "http://127.0.0.1:54321"), true);
});
test("foreign http Origin on a different loopback port is blocked", () => {
// A different local app on another 127.0.0.1 port is still cross-origin to us.
const r = req({ host: "127.0.0.1:54321", origin: "http://127.0.0.1:9999" });
assert.equal(isCrossSiteRequest(r, "http://127.0.0.1:54321"), true);
});
test("same-origin check does not trust a DNS-rebound Host header", () => {
const r = req({ host: "attacker.example:54321", origin: "http://attacker.example:54321" });
assert.equal(isCanonicalHost(r, "127.0.0.1:54321"), false);
assert.equal(isCrossSiteRequest(r, "http://127.0.0.1:54321"), true);
});
test("Sec-Fetch-Site: cross-site (no Origin) is blocked", () => {
const r = req({ host: "127.0.0.1:54321", "sec-fetch-site": "cross-site" });
assert.equal(isCrossSiteRequest(r, "http://127.0.0.1:54321"), true);
});
test("Sec-Fetch-Site: same-site (no Origin) is blocked", () => {
// Our legit UI is same-ORIGIN (Sec-Fetch-Site: same-origin). A same-site but
// not same-origin request would be another local app on a sibling port —
// exactly what we want to keep out.
const r = req({ host: "127.0.0.1:54321", "sec-fetch-site": "same-site" });
assert.equal(isCrossSiteRequest(r, "http://127.0.0.1:54321"), true);
});
test("Sec-Fetch-Site: same-origin (no Origin) is allowed", () => {
const r = req({ host: "127.0.0.1:54321", "sec-fetch-site": "same-origin" });
assert.equal(isCrossSiteRequest(r, "http://127.0.0.1:54321"), false);
});
test("state-changing and OAuth status routes require a capability token", () => {
assert.equal(requiresCapabilityToken("/api/install"), true);
assert.equal(requiresCapabilityToken("/oauth-status"), true);
assert.equal(requiresCapabilityToken("/auth/callback/conn"), true);
assert.equal(requiresCapabilityToken("/setup"), false);
});
test("capability token accepts the private header or OAuth callback query", () => {
const token = "secret-token";
assert.equal(
hasCapabilityToken(req({ "x-connector-namespace-token": token }), new URL("http://127.0.0.1/api/state"), token),
true,
);
assert.equal(
hasCapabilityToken(req({}), new URL(`http://127.0.0.1/auth/callback/conn?cn_token=${token}`), token),
true,
);
assert.equal(
hasCapabilityToken(req({ "x-connector-namespace-token": "wrong" }), new URL("http://127.0.0.1/api/state"), token),
false,
);
assert.equal(
hasCapabilityToken(req({}), new URL(`http://127.0.0.1/api/state?cn_token=${token}`), token),
false,
"callback query tokens must not authorize API routes",
);
assert.equal(
hasCapabilityToken(req({}), new URL(`http://127.0.0.1/oauth-status?cn_token=${token}`), token),
false,
"callback query tokens must not authorize OAuth polling",
);
});
test("request bodies larger than 64 KiB are rejected", async () => {
await assert.rejects(
parseBody(Readable.from([Buffer.alloc(64 * 1024 + 1)])),
(err) => err?.constructor?.name === "RequestBodyTooLargeError",
);
});
test("saved namespace coordinates reject ARM path injection", () => {
assert.equal(isValidConfig({
subscriptionId: "f34b22a3-2202-4fb1-b040-1332bd928c84",
resourceGroup: "jack-sandboxgroup-rg",
gatewayName: "yeah-github-cli",
}), true);
assert.equal(isValidConfig({
subscriptionId: "bad/value",
resourceGroup: "rg",
gatewayName: "gw",
}), false);
});
test("idempotent mutations replay one in-flight result", async () => {
const operations = new Map();
let calls = 0;
const start = async () => {
calls++;
await new Promise((resolve) => setTimeout(resolve, 5));
return { ok: true };
};
const [first, second] = await Promise.all([
runIdempotentOperation(operations, "install:request", start),
runIdempotentOperation(operations, "install:request", start),
]);
assert.equal(calls, 1);
assert.deepEqual(first, { ok: true });
assert.deepEqual(second, { ok: true });
assert.deepEqual(await runIdempotentOperation(operations, "install:request", start), { ok: true });
assert.equal(calls, 1);
});
test("install rejects missing idempotency request ids before ARM work", async (t) => {
const instanceId = `request-id-${Date.now()}`;
t.after(() => stopServer(instanceId));
const entry = await startServer(instanceId, {
config: { subscriptionId: "sub", resourceGroup: "rg", gatewayName: "gw" },
});
const response = await fetch(`${entry.url}api/install`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-connector-namespace-token": entry.token,
},
body: JSON.stringify({ apiName: "test" }),
});
assert.deepEqual(await response.json(), { error: "invalid requestId" });
});
test("canvas servers keep independent active namespace configs", async (t) => {
const a = `state-a-${Date.now()}`;
const b = `state-b-${Date.now()}`;
t.after(async () => Promise.all([stopServer(a), stopServer(b)]));
const configA = { subscriptionId: "sub-a", resourceGroup: "rg-a", gatewayName: "gw-a" };
const configB = { subscriptionId: "sub-b", resourceGroup: "rg-b", gatewayName: "gw-b" };
await Promise.all([
startServer(a, { config: configA }),
startServer(b, { config: configB }),
]);
assert.deepEqual(getServerConfig(a), configA);
assert.deepEqual(getServerConfig(b), configB);
// A rehydrate may carry a newer persisted default from panel B. Existing
// panel A must retain its own active namespace.
await startServer(a, { defaultConfig: configB });
assert.deepEqual(getServerConfig(a), configA);
});
test("loopback listen rejects bind errors", async () => {
const error = new Error("bind failed");
const server = new EventEmitter();
server.listen = () => queueMicrotask(() => server.emit("error", error));
await assert.rejects(listenOnLoopback(server), error);
});
+115
View File
@@ -0,0 +1,115 @@
// State management — persists gateway config and tracks added connectors.
import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync, chmodSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import { armSegment } from "./armClient.mjs";
const STORAGE_DIR = join(process.env.COPILOT_HOME || join(homedir(), ".copilot"), "extensions", "connector-namespaces", "artifacts");
const CONFIG_FILE = join(STORAGE_DIR, "gateway-config.json");
// Persisted default for newly opened canvas instances.
const addedConnectors = new Map();
let savedConfig = null;
// ---------------------------------------------------------------------------
// Persistent config (gateway selection)
// ---------------------------------------------------------------------------
function ensureStorageDir() {
mkdirSync(STORAGE_DIR, { recursive: true, mode: 0o700 });
chmodSync(STORAGE_DIR, 0o700);
}
export function isValidConfig(data) {
const complete = (
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
);
if (!complete) return false;
try {
armSegment(data.subscriptionId);
armSegment(data.resourceGroup);
armSegment(data.gatewayName);
return true;
} catch {
return false;
}
}
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)) {
savedConfig = data;
return data;
}
}
} catch { /* ignore corrupt file */ }
savedConfig = null;
return null;
}
export function saveConfig(config) {
if (!isValidConfig(config)) {
throw new Error("Invalid connector namespace configuration.");
}
ensureStorageDir();
savedConfig = config;
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { encoding: "utf-8", mode: 0o600 });
chmodSync(CONFIG_FILE, 0o600);
}
export function clearConfig() {
// Remove the file outright rather than leaving a "{}" stub that a later
// loadSavedConfig could misread as a valid selection.
try {
unlinkSync(CONFIG_FILE);
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
savedConfig = null;
}
export function getSavedConfig() {
return savedConfig;
}
// ---------------------------------------------------------------------------
// 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);
}
@@ -0,0 +1,32 @@
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
test("saved namespace config uses private directory and file permissions", async (t) => {
const root = mkdtempSync(join(tmpdir(), "connector-state-"));
const previousHome = process.env.COPILOT_HOME;
process.env.COPILOT_HOME = root;
t.after(() => {
if (previousHome === undefined) delete process.env.COPILOT_HOME;
else process.env.COPILOT_HOME = previousHome;
rmSync(root, { recursive: true, force: true });
});
const state = await import(`./state.mjs?permissions=${Date.now()}`);
const config = {
subscriptionId: "00000000-0000-0000-0000-000000000000",
resourceGroup: "example-rg",
gatewayName: "example-gateway",
};
state.saveConfig(config);
const storageDir = join(root, "extensions", "connector-namespaces", "artifacts");
const configFile = join(storageDir, "gateway-config.json");
assert.deepEqual(JSON.parse(readFileSync(configFile, "utf8")), config);
if (process.platform !== "win32") {
assert.equal(statSync(storageDir).mode & 0o777, 0o700);
assert.equal(statSync(configFile).mode & 0o777, 0o600);
}
});
@@ -0,0 +1,128 @@
# MCP smoke test
A standalone harness that proves the Microsoft first-party MCP servers behind a
connector gateway actually work end-to-end:
```
connect → initialize → tools/list → a safe tools/call
```
It imports the `connector-namespaces` extension's real pipeline (`install.mjs`,
`catalog.mjs`, `armClient.mjs`) and connects through the same native Streamable
HTTP endpoint that the extension writes to the Copilot CLI config. The probe
uses the configured `X-API-Key`, follows `Mcp-Session-Id`, and accepts standard
JSON or SSE JSON-RPC responses.
The whole point: it runs with **Node and Azure CLI**. No Copilot app, no
canvas, no UI. Hand it to anyone (e.g. Arjun) and they can reproduce an MCP
server issue locally.
## Prerequisites
1. **Azure CLI signed in with `az login`.** The harness asks Azure CLI for the
same short-lived ARM token as the extension.
2. **A gateway already picked once.** The harness reads gateway coordinates from
`~/.copilot/extensions/connector-namespaces/artifacts/gateway-config.json`
(`{ subscriptionId, resourceGroup, gatewayName }`). Pick a gateway once in
the connector-namespaces canvas, or write that file by hand.
3. **Node 20+** (developed on Node 24).
## Run it
```bash
node extensions/connector-namespaces/test/smoke.mjs
```
Options:
| flag | effect |
|---|---|
| `--only=a,b` | only test these `apiName`s (comma-separated) |
| `--limit=N` | stop after N connectable servers |
| `--open-consent` | open consent URLs in the browser for OAuth servers that need it |
| `--no-cleanup` | leave fresh keyless installs in place (default: uninstall them) |
Examples:
```bash
# just the three already-connected WorkIQ servers
node extensions/connector-namespaces/test/smoke.mjs --only=WorkIQMail,WorkIQSharePoint,WorkIQTeams
# first 5 connectable servers, open any consent prompts
node extensions/connector-namespaces/test/smoke.mjs --limit=5 --open-consent
```
## One-time consent, then headless forever
This is the key behavior. OAuth-backed servers (most of them) need a human to
consent **once** in a browser. The model:
1. **First run** hits a server that needs consent → the harness prints a consent
URL and marks it `NEEDS_CONSENT`. It saves a pending record to
`~/.copilot/extensions/connector-namespaces/artifacts/smoke-pending-consent.json` (not in
the repo). No tool call is attempted.
2. **You open that URL once** and sign in / consent. After sign-in the browser
may show "this site can't be reached" on a `127.0.0.1:7333/auth/callback/`
page — **that is expected and harmless.** Consent completes gateway-side; the
loopback page is just a redirect target and nothing is listening on it.
3. **Re-run the harness.** It sees the pending record, confirms the gateway
connection is now `Connected`, finishes the install (mints the API key,
writes the CLI entry), and probes it headless. From then on it's reused with
zero human interaction.
So the server taxonomy is:
- **Already connected** (e.g. the three WorkIQ servers) → probed immediately.
- **Keyless / SP / AAD** (e.g. Microsoft Learn Docs) → installed + probed +
cleaned up immediately, no consent.
- **Consent-once OAuth** → surfaced on run 1, converts to headless on run 2.
That's why the **first** run may probe fewer than 10 servers — the rest are
waiting on their one-time consent. Consent the URLs it prints, re-run, and the
count climbs. This is inherent to the consent model, not a harness bug.
## Tool-call safety
The harness never blindly calls the first tool a server advertises (mutation
risk). `safe-tools.mjs` picks a tool to call by:
1. a **curated map** of known-safe read tools per server (e.g. Microsoft Learn
Docs → `microsoft_docs_search`, WorkIQ Teams → `ListTeams`), then
2. a **read-only-name heuristic** fallback — the first tool whose name starts
with `list`/`get`/`search`/`read`/`find`/… **and** whose required arguments
are empty or trivially fillable with benign values.
If nothing looks safe, it does `tools/list` only and records the call as
`SKIPPED` (tools proven to load, no call made). Expand the curated map in
`safe-tools.mjs` as you learn each server.
## Reading the report
Each run prints a summary and writes two files to `test/reports/` (gitignored —
they contain live endpoint URLs):
- `mcp-smoke-<timestamp>.log` — human-readable table. **This is the handoff
artifact** — attach it to a bug or send it to whoever needs to repro.
- `mcp-smoke-<timestamp>.json` — machine-readable, same data.
Per server you get: classification, `initialize` pass/fail + latency, tool
count, which tool was called and why, the call result preview or error, and a
direct transport error on failure. API keys are redacted; endpoint URLs are not,
which is why the reports stay out of git.
Exit code is **non-zero if any probed server failed a step**, so it's CI-usable.
## Files
| file | role |
|---|---|
| `smoke.mjs` | orchestrator — bootstrap, classify each server, probe, report |
| `mcp-probe.mjs` | drives the native Streamable HTTP JSON-RPC handshake |
| `safe-tools.mjs` | curated safe-read-tool map + read-only heuristic + arg filler |
| `reports/` | generated `.log` + `.json` artifacts (gitignored) |
## Scope
Microsoft first-party servers only (`category === "Microsoft"` in the catalog).
Partner servers (Box, Celonis, …) are filtered out — they need partner accounts
and OAuth we can't automate.
@@ -0,0 +1,173 @@
// MCP probe — drives the gateway's native Streamable HTTP endpoint.
import { pickSafeTool } from "./safe-tools.mjs";
const PROTOCOL_VERSION = "2025-06-18";
function parseResponseBody(text, contentType, expectedId) {
if (!text.trim()) return null;
if (contentType.toLowerCase().includes("text/event-stream")) {
const messages = [];
for (const event of text.split(/\r?\n\r?\n/)) {
const data = event
.split(/\r?\n/)
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice(5).trim())
.join("\n");
if (data && data !== "[DONE]") messages.push(JSON.parse(data));
}
return messages.find((message) => message?.id === expectedId) || null;
}
return JSON.parse(text);
}
class HttpClient {
constructor(url, key) {
this.url = url;
this.key = key;
this.sessionId = null;
}
async post(message, timeoutMs, expectedId = null) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const headers = {
Accept: "application/json, text/event-stream",
"Content-Type": "application/json",
"X-API-Key": this.key,
};
if (this.sessionId) headers["Mcp-Session-Id"] = this.sessionId;
const response = await fetch(this.url, {
method: "POST",
headers,
body: JSON.stringify(message),
signal: controller.signal,
});
const nextSessionId = response.headers.get("mcp-session-id");
if (nextSessionId) this.sessionId = nextSessionId;
const text = await response.text();
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${text.slice(0, 300)}`);
}
return parseResponseBody(text, response.headers.get("content-type") || "", expectedId);
} catch (error) {
if (error?.name === "AbortError") {
throw new Error(`timeout after ${timeoutMs}ms`);
}
throw error;
} finally {
clearTimeout(timer);
}
}
request(id, method, params = {}, timeoutMs = 30000) {
return this.post({ jsonrpc: "2.0", id, method, params }, timeoutMs, id);
}
notify(method, params = {}, timeoutMs = 30000) {
return this.post({ jsonrpc: "2.0", method, params }, timeoutMs);
}
}
function summarizeResult(result) {
if (!result || typeof result !== "object") return "";
if (Array.isArray(result.content)) {
const text = result.content
.map((content) => (typeof content?.text === "string" ? content.text : ""))
.join(" ")
.trim();
return text.slice(0, 200);
}
return JSON.stringify(result).slice(0, 200);
}
// Probe one server end-to-end.
// server: { apiName, displayName, configName, url, key }
// -> structured result with per-step pass/fail + latency.
export async function probe(server) {
const out = {
apiName: server.apiName,
displayName: server.displayName,
steps: {
initialize: { ok: false },
toolsList: { ok: false },
toolsCall: { ok: false, status: "pending" },
},
toolCount: 0,
toolNames: [],
toolCalled: null,
toolSource: null,
error: null,
};
const client = new HttpClient(server.url, server.key);
try {
const t0 = Date.now();
const init = await client.request(1, "initialize", {
protocolVersion: PROTOCOL_VERSION,
capabilities: {},
clientInfo: { name: "mcp-smoke", version: "1.0.0" },
}, 45000);
out.steps.initialize.latencyMs = Date.now() - t0;
if (!init) throw new Error("initialize returned no JSON-RPC response");
if (init.error) throw new Error(`initialize error: ${JSON.stringify(init.error).slice(0, 300)}`);
const info = init.result?.serverInfo;
if (!init.result || !(info || init.result.protocolVersion)) {
throw new Error("initialize returned no serverInfo / protocolVersion");
}
out.steps.initialize.ok = true;
out.serverInfo = info ? `${info.name || "?"}@${info.version || "?"}` : "(no serverInfo)";
await client.notify("notifications/initialized");
const t1 = Date.now();
const list = await client.request(2, "tools/list", {}, 30000);
out.steps.toolsList.latencyMs = Date.now() - t1;
if (!list) throw new Error("tools/list returned no JSON-RPC response");
if (list.error) throw new Error(`tools/list error: ${JSON.stringify(list.error).slice(0, 300)}`);
const tools = list.result?.tools || [];
out.toolCount = tools.length;
out.toolNames = tools.map((tool) => tool?.name).filter(Boolean);
if (tools.length < 1) throw new Error("tools/list returned 0 tools");
out.steps.toolsList.ok = true;
const pick = pickSafeTool(server, tools);
if (!pick || pick.skip) {
out.steps.toolsCall.status = "skipped";
out.steps.toolsCall.ok = true;
out.steps.toolsCall.note = pick?.reason || "no safe read-only tool found";
out.toolSource = pick?.source || null;
} else {
out.toolCalled = pick.tool;
out.toolSource = pick.source;
const t2 = Date.now();
const call = await client.request(3, "tools/call", { name: pick.tool, arguments: pick.args }, 45000);
out.steps.toolsCall.latencyMs = Date.now() - t2;
if (!call) {
out.steps.toolsCall.status = "failed";
out.steps.toolsCall.error = "tools/call returned no JSON-RPC response";
} else if (call.error) {
out.steps.toolsCall.status = "failed";
out.steps.toolsCall.error = JSON.stringify(call.error).slice(0, 300);
} else if (call.result?.isError) {
out.steps.toolsCall.status = "failed";
out.steps.toolsCall.error = summarizeResult(call.result);
} else {
out.steps.toolsCall.status = "passed";
out.steps.toolsCall.ok = true;
out.steps.toolsCall.result = "response received";
}
}
} catch (error) {
out.error = error.message;
}
out.ok = out.steps.initialize.ok && out.steps.toolsList.ok && out.steps.toolsCall.ok && !out.error;
out.status = !out.ok
? "failed"
: out.steps.toolsCall.status === "skipped"
? "skipped"
: "passed";
return out;
}
@@ -0,0 +1,168 @@
// Safe read-tool selection for the MCP smoke harness.
//
// We never blindly call the first tool a server advertises — many servers lead
// with mutating tools (SendMail, CreateItem, DeleteMessage). This module picks a
// tool that is safe to call automatically: read-only by name, with an input
// schema we can satisfy using benign placeholder values. If nothing qualifies,
// the caller records the server as SKIPPED (tools proven to load, no call made).
// Curated overrides keyed by a predicate over the server identity. Use this when
// the heuristic would skip a server that actually has a known-safe read tool, or
// when we want to pin a specific tool/argument set. `tool` must match a tool name
// from tools/list; `args` is merged over the auto-filled args.
const CURATED = [
{
name: "Microsoft Learn Docs",
match: (s) => /learn|docs/i.test(s.apiName) || /learn|docs/i.test(s.displayName),
tool: "microsoft_docs_search",
args: { query: "azure connectors", question: "what are azure connectors" },
},
{
name: "WorkIQ Teams",
match: (s) => /teams/i.test(s.apiName) || /teams/i.test(s.displayName),
tool: "ListTeams",
args: {},
},
{
// SearchMessagesQueryParameters declares queryParameters optional, but the
// server enforces "queryParameters OR nextLink required" — a cross-field
// rule the JSON schema doesn't express, so the no-arg heuristic call fails.
// Pin a benign read-only OData query that returns at most one message.
name: "WorkIQ Mail",
match: (s) => /outlookmail/i.test(s.apiName) || /mail/i.test(s.displayName),
tool: "SearchMessagesQueryParameters",
args: { queryParameters: "?$top=1" },
},
{
// copilot_chat is the only tool Work IQ Copilot exposes. Its name doesn't
// match the read-only heuristic, so without this pin the harness would skip
// the call. A benign one-sentence question is a safe read-style call.
name: "WorkIQ Copilot",
match: (s) => /copilotchat/i.test(s.apiName) || /copilot/i.test(s.displayName),
tool: "copilot_chat",
args: { message: "What is Microsoft Azure? Answer in one sentence." },
},
{
// Word advertises CreateDocument/AddComment/ReplyToComment (all mutating)
// and GetDocumentContent, which needs a real document on a drive this
// tenant can't resolve ("Invalid hostname for this tenancy" — a backend
// config issue, not an argument problem). No safe no-arg read tool exists,
// so prove the tools load and skip the call instead of a false failure.
name: "WorkIQ Word",
match: (s) => /wordmcp/i.test(s.apiName) || /^word\b/i.test(s.displayName || ""),
skip: true,
reason: "no safe no-arg read tool (GetDocumentContent needs a real document + a tenant drive)",
},
];
const READ_ONLY_NAME = /^(list|get|search|read|find|describe|show|fetch|lookup|query|count)/i;
// Names that look read-only but are known to mutate or are too risky to auto-run.
const DENY_NAME = /(send|create|update|delete|remove|add|set|post|put|patch|write|upload|move|copy|rename|share|invite|reply|forward|draft|flag|ingest|execute|run|trigger)/i;
function isFillableSchema(schema) {
if (!schema || typeof schema !== "object") return true;
const required = Array.isArray(schema.required) ? schema.required : [];
const props = schema.properties || {};
for (const key of required) {
const prop = props[key];
if (!prop) return false; // required but undescribed → can't satisfy safely
if (!fillValue(prop, key).ok) return false;
}
return true;
}
// Produce a benign placeholder for a single property schema.
function fillValue(prop, key) {
if (!prop || typeof prop !== "object") return { ok: true, value: "test" };
if (prop.default !== undefined) return { ok: true, value: prop.default };
if (Array.isArray(prop.enum) && prop.enum.length > 0) return { ok: true, value: prop.enum[0] };
const type = Array.isArray(prop.type) ? prop.type.find((t) => t !== "null") : prop.type;
switch (type) {
case "string": {
if (/mail|email|upn|recipient|to|address/i.test(key)) return { ok: true, value: "test@example.com" };
if (/url|uri|link/i.test(key)) return { ok: true, value: "https://example.com" };
if (/query|search|q|term|keyword|text|question|prompt/i.test(key)) return { ok: true, value: "azure" };
return { ok: true, value: "test" };
}
case "number":
case "integer":
return { ok: true, value: 1 };
case "boolean":
return { ok: true, value: false };
default:
// object/array/unknown required input → we can't safely fabricate it.
return { ok: false };
}
}
// Build the argument object for a tool from its required input schema.
function buildArgs(tool) {
const schema = tool.inputSchema || tool.input_schema || {};
const required = Array.isArray(schema.required) ? schema.required : [];
const props = schema.properties || {};
const args = {};
for (const key of required) {
const filled = fillValue(props[key] || {}, key);
if (!filled.ok) return { ok: false };
args[key] = filled.value;
}
return { ok: true, args };
}
// Pick a safe tool to call for a server.
// server: { apiName, displayName, configName }
// tools: the array returned by tools/list
// → { tool, args, source } | null (null means "no safe tool, record SKIPPED")
export function pickSafeTool(server, tools) {
if (!Array.isArray(tools) || tools.length === 0) return null;
const byName = new Map(tools.map((t) => [t.name, t]));
// 1. Curated override: a skip directive, or a pinned tool if it's advertised.
for (const entry of CURATED) {
if (!entry.match(server)) continue;
if (entry.skip) {
return { skip: true, reason: entry.reason || "curated skip", source: "curated-skip" };
}
if (byName.has(entry.tool)) {
const tool = byName.get(entry.tool);
const built = buildArgs(tool);
const base = built.ok ? built.args : {};
return { tool: entry.tool, args: { ...base, ...entry.args }, source: "curated" };
}
}
// 2. Heuristic. Two passes, preferring tools we can call with NO fabricated
// arguments — a required `id`/`resourceId` filled with a placeholder will
// pass the schema check but fail at runtime (fake message id, fake
// resource). A read tool with no required args is both safer and far more
// likely to actually succeed, so try those first.
const candidate = (tool, requireEmpty) => {
const nm = tool.name || "";
const annotations = tool.annotations || {};
if (annotations.readOnlyHint !== true || annotations.destructiveHint === true) return null;
if (!READ_ONLY_NAME.test(nm)) return null;
if (DENY_NAME.test(nm)) return null;
const schema = tool.inputSchema || tool.input_schema || {};
const required = Array.isArray(schema.required) ? schema.required : [];
if (requireEmpty && required.length > 0) return null;
if (!isFillableSchema(schema)) return null;
const built = buildArgs(tool);
if (!built.ok) return null;
return { tool: nm, args: built.args, source: requireEmpty ? "heuristic-noargs" : "heuristic" };
};
for (const tool of tools) {
const pick = candidate(tool, true);
if (pick) return pick;
}
for (const tool of tools) {
const pick = candidate(tool, false);
if (pick) return pick;
}
return null;
}
export const _internals = { buildArgs, isFillableSchema, fillValue, READ_ONLY_NAME, DENY_NAME };
@@ -0,0 +1,150 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { pickSafeTool, _internals } from "./safe-tools.mjs";
test("curated servers pin known-safe tools and arguments", () => {
const learn = pickSafeTool(
{ apiName: "learn", displayName: "Microsoft Learn" },
[{
name: "microsoft_docs_search",
inputSchema: {
type: "object",
required: ["query", "question"],
properties: {
query: { type: "string" },
question: { type: "string" },
},
},
}],
);
assert.deepEqual(learn, {
tool: "microsoft_docs_search",
args: { query: "azure connectors", question: "what are azure connectors" },
source: "curated",
});
const mail = pickSafeTool(
{ apiName: "outlookmail", displayName: "Mail" },
[{ name: "SearchMessagesQueryParameters", inputSchema: { type: "object", properties: {} } }],
);
assert.deepEqual(mail, {
tool: "SearchMessagesQueryParameters",
args: { queryParameters: "?$top=1" },
source: "curated",
});
});
test("curated unsafe servers are skipped without selecting a tool", () => {
const result = pickSafeTool(
{ apiName: "wordmcp", displayName: "Word" },
[{ name: "GetDocumentContent", inputSchema: { type: "object", required: ["documentId"] } }],
);
assert.equal(result.skip, true);
assert.equal(result.source, "curated-skip");
assert.match(result.reason, /no safe no-arg read tool/);
});
test("deny-list terms beat read-looking prefixes", () => {
const result = pickSafeTool(
{ apiName: "generic", displayName: "Generic" },
[
{ name: "GetAndDeleteMessage", annotations: { readOnlyHint: true }, inputSchema: { type: "object" } },
{ name: "ListAndSendMail", annotations: { readOnlyHint: true }, inputSchema: { type: "object" } },
{ name: "SearchRecords", annotations: { readOnlyHint: true }, inputSchema: { type: "object" } },
],
);
assert.deepEqual(result, { tool: "SearchRecords", args: {}, source: "heuristic-noargs" });
});
test("schema filling uses benign values and rejects complex required input", () => {
const built = _internals.buildArgs({
inputSchema: {
required: ["recipientEmail", "resourceUrl", "query", "count", "enabled", "kind", "preset"],
properties: {
recipientEmail: { type: "string" },
resourceUrl: { type: "string" },
query: { type: "string" },
count: { type: "integer" },
enabled: { type: "boolean" },
kind: { type: "string", enum: ["summary", "full"] },
preset: { type: "string", default: "safe" },
},
},
});
assert.deepEqual(built, {
ok: true,
args: {
recipientEmail: "test@example.com",
resourceUrl: "https://example.com",
query: "azure",
count: 1,
enabled: false,
kind: "summary",
preset: "safe",
},
});
assert.deepEqual(
_internals.buildArgs({
input_schema: {
required: ["payload"],
properties: { payload: { type: "object" } },
},
}),
{ ok: false },
);
});
test("heuristics prefer no-argument reads and return null when none are safe", () => {
const preferred = pickSafeTool(
{ apiName: "generic", displayName: "Generic" },
[
{
name: "GetRecord",
inputSchema: {
required: ["query"],
properties: { query: { type: "string" } },
},
},
{ name: "ListRecords", annotations: { readOnlyHint: true }, inputSchema: { type: "object" } },
],
);
assert.deepEqual(preferred, { tool: "ListRecords", args: {}, source: "heuristic-noargs" });
assert.equal(
pickSafeTool(
{ apiName: "generic", displayName: "Generic" },
[
{ name: "CreateRecord", inputSchema: { type: "object" } },
{
name: "GetRecord",
inputSchema: {
required: ["payload"],
properties: { payload: { type: "array" } },
},
},
],
),
null,
);
assert.equal(pickSafeTool({ apiName: "generic", displayName: "Generic" }, []), null);
});
test("heuristics require an explicit read-only non-destructive annotation", () => {
assert.equal(
pickSafeTool(
{ apiName: "generic", displayName: "Generic" },
[
{ name: "GetAndArchiveMessage", inputSchema: { type: "object" } },
{ name: "ListAndApproveRequests", inputSchema: { type: "object" } },
],
),
null,
);
assert.equal(
pickSafeTool(
{ apiName: "generic", displayName: "Generic" },
[{ name: "ListRecords", annotations: { readOnlyHint: true, destructiveHint: true }, inputSchema: { type: "object" } }],
),
null,
);
});
@@ -0,0 +1,328 @@
// MCP smoke-test orchestrator.
//
// Standalone harness that proves the Microsoft first-party MCP servers behind a
// connector gateway actually work end-to-end: connect -> initialize ->
// tools/list -> a safe tools/call. It imports the extension's real pipeline
// (install.mjs, catalog.mjs, armClient.mjs) and connects through the same native
// Streamable HTTP endpoint persisted for the Copilot CLI.
//
// Runs with `node` and a signed-in Azure CLI — no Copilot app required — so it
// can be handed to someone else to reproduce MCP issues. See README.md.
//
// Usage:
// node extensions/connector-namespaces/test/smoke.mjs [options]
//
// Options:
// --only=a,b only test these apiNames (comma-separated)
// --limit=N stop after N connectable servers
// --open-consent open consent URLs in the browser for servers that need it
// --no-cleanup do not uninstall fresh keyless installs afterward
import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from "node:fs";
import { join, dirname } from "node:path";
import { homedir } from "node:os";
import { fileURLToPath } from "node:url";
import { loadSavedConfig } from "../state.mjs";
import { getToken } from "../armClient.mjs";
import { CATEGORY } from "../categories.mjs";
import {
installConnector,
finishInstall,
getInstalledState,
getConnectionStatus,
getMcpEndpointUrl,
mintApiKey,
uninstallConnector,
openInBrowser,
assertSafeMcpTarget,
} from "../install.mjs";
import { fetchCatalog } from "../catalog.mjs";
import { probe } from "./mcp-probe.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));
const REPORTS_DIR = join(HERE, "reports");
const PROFILE_MCP_PATH = join(process.env.COPILOT_HOME || join(homedir(), ".copilot"), "mcp-config.json");
const ARTIFACTS_DIR = join(process.env.COPILOT_HOME || join(homedir(), ".copilot"), "extensions", "connector-namespaces", "artifacts");
const PENDING_FILE = join(ARTIFACTS_DIR, "smoke-pending-consent.json");
// The loopback callback never has to be listening — gateway-side consent
// completes in the browser; this URL is only embedded in the redirect.
const CALLBACK_BASE = "http://127.0.0.1:7333/auth/callback/";
function parseArgs(argv) {
const opts = { only: null, limit: Infinity, openConsent: false, cleanup: true };
for (const a of argv) {
if (a.startsWith("--only=")) opts.only = new Set(a.slice(7).split(",").map((s) => s.trim()).filter(Boolean));
else if (a.startsWith("--limit=")) {
const limit = Number.parseInt(a.slice(8), 10);
opts.limit = Number.isNaN(limit) ? Infinity : limit;
}
else if (a === "--open-consent") opts.openConsent = true;
else if (a === "--no-cleanup") opts.cleanup = false;
}
return opts;
}
function readPending() {
try {
if (existsSync(PENDING_FILE)) return JSON.parse(readFileSync(PENDING_FILE, "utf-8"));
} catch { /* ignore corrupt */ }
return {};
}
function writePending(map) {
if (!existsSync(ARTIFACTS_DIR)) mkdirSync(ARTIFACTS_DIR, { recursive: true, mode: 0o700 });
chmodSync(ARTIFACTS_DIR, 0o700);
if (existsSync(PENDING_FILE)) chmodSync(PENDING_FILE, 0o600);
writeFileSync(PENDING_FILE, JSON.stringify(map, null, 2), { encoding: "utf-8", mode: 0o600 });
chmodSync(PENDING_FILE, 0o600);
}
// Read the native HTTP MCP credentials from the CLI config.
function credsFromCli(configName) {
try {
const cfg = JSON.parse(readFileSync(PROFILE_MCP_PATH, "utf-8"));
const entry = cfg.mcpServers?.[configName];
if (entry?.url && entry?.headers?.["X-API-Key"]) {
return { url: entry.url, key: entry.headers["X-API-Key"] };
}
} catch { /* ignore */ }
return null;
}
// Resolve url+key for an installed+connected server, minting a key if the CLI
// entry is missing (e.g. installed at the gateway but not added to the CLI).
async function resolveCreds(config, state) {
const fromCli = credsFromCli(state.configName);
if (fromCli) return fromCli;
const url = await getMcpEndpointUrl(config, state.configName);
if (!url) return null;
const key = await mintApiKey(config, state.configName);
return { url, key };
}
function redact(text) {
if (typeof text !== "string") return text;
// Redact anything that looks like a gateway API key in URLs or headers.
return text.replace(/([?&](?:key|code|api[-_]?key)=)[^&\s"]+/gi, "$1<redacted>");
}
// Collapse CR/LF so user/network-derived error text can't forge extra log
// lines (CodeQL js/log-injection). Kept separate from redact() so the
// pretty-printed JSON report still retains its newlines.
function logLine(text) {
return redact(String(text)).replace(/[\r\n]+/g, " ");
}
const C = {
reset: "\x1b[0m", dim: "\x1b[2m", bold: "\x1b[1m",
green: "\x1b[32m", red: "\x1b[31m", yellow: "\x1b[33m", cyan: "\x1b[36m",
};
const tick = (ok) => (ok ? `${C.green}PASS${C.reset}` : `${C.red}FAIL${C.reset}`);
async function main() {
const opts = parseArgs(process.argv.slice(2));
// 1. Bootstrap: gateway coords + ARM token (fail fast).
const config = loadSavedConfig();
if (!config?.subscriptionId || !config?.resourceGroup || !config?.gatewayName) {
console.error(`${C.red}No gateway config found.${C.reset} Expected ${join(ARTIFACTS_DIR, "gateway-config.json")}.`);
console.error("Pick a gateway once in the connector-namespaces canvas, or create that file with { subscriptionId, resourceGroup, gatewayName }.");
process.exit(2);
}
try {
await getToken();
} catch (err) {
console.error(`${C.red}Could not get an ARM token.${C.reset} Sign in to Azure when the browser opens.`);
console.error(String(err.message || err).slice(0, 300));
process.exit(2);
}
console.log(`${C.bold}MCP smoke test${C.reset} gateway=${C.cyan}${config.gatewayName}${C.reset} rg=${config.resourceGroup}`);
// 2. Target set: Microsoft first-party MCP servers only.
const catalog = await fetchCatalog(config.subscriptionId, config.resourceGroup, config.gatewayName);
let servers = catalog.filter((s) => s.category === CATEGORY.microsoft);
if (opts.only) servers = servers.filter((s) => opts.only.has(s.apiName));
console.log(`${C.dim}${servers.length} Microsoft servers in catalog${C.reset}\n`);
const installedState = await getInstalledState(config);
const pending = readPending();
const results = [];
let connectable = 0;
for (const server of servers) {
if (connectable >= opts.limit) break;
const label = server.displayName || server.apiName;
process.stdout.write(`${C.bold}${label}${C.reset} ${C.dim}(${server.apiName})${C.reset}\n`);
const record = { apiName: server.apiName, displayName: label, classification: null, probe: null, cleanup: false };
let creds = null;
try {
const state = installedState[server.apiName];
const pend = pending[server.apiName];
if (pend) {
// We surfaced a consent URL on a previous run — check if it's done now.
const status = await getConnectionStatus(config, pend.connName);
if (status === "Connected") {
console.log(` ${C.dim}consent completed, finishing install...${C.reset}`);
const fin = await finishInstall(config, server.apiName, label, pend.connName, pend.location);
creds = credsFromCli(fin.configName) || { url: fin.endpointUrl, key: null };
record.classification = "consented-now";
delete pending[server.apiName];
writePending(pending);
} else {
record.classification = "pending-consent";
console.log(` ${C.yellow}PENDING_CONSENT${C.reset} still ${status}. Consent: ${pend.consentUrl}`);
if (opts.openConsent) await openInBrowser(pend.consentUrl);
results.push(record);
continue;
}
} else if (state?.installed && state.connectionStatus === "Connected") {
record.classification = "probe-only";
creds = await resolveCreds(config, state);
} else if (state?.installed) {
record.classification = "installed-not-connected";
console.log(` ${C.yellow}SKIP${C.reset} installed but connection is ${state.connectionStatus}`);
results.push(record);
continue;
} else {
// Not installed — try a fresh install.
const res = await installConnector(config, server.apiName, label, CALLBACK_BASE);
if (res?.ok) {
record.classification = "fresh-install";
record.cleanup = opts.cleanup;
creds = credsFromCli(res.configName) || { url: res.endpointUrl, key: null };
} else if (res?.needsConsent) {
record.classification = "needs-consent";
pending[server.apiName] = {
connName: res.connName, location: res.location,
displayName: label, consentUrl: res.consentUrl, savedAt: Date.now(),
};
writePending(pending);
console.log(` ${C.yellow}NEEDS_CONSENT${C.reset} consent once, then re-run. URL:\n ${res.consentUrl}`);
if (opts.openConsent) await openInBrowser(res.consentUrl);
results.push(record);
continue;
} else {
throw new Error("installConnector returned neither ok nor needsConsent");
}
}
if (!creds?.url || !creds?.key) {
record.error = "could not resolve url+key for probe";
console.log(` ${C.red}FAIL${C.reset} ${record.error}`);
results.push(record);
continue;
}
assertSafeMcpTarget(creds.url);
connectable++;
const r = await probe({ ...server, displayName: label, url: creds.url, key: creds.key });
record.probe = r;
const callLine = r.steps.toolsCall.status === "skipped"
? `${C.yellow}SKIPPED${C.reset}`
: `${tick(r.steps.toolsCall.ok)}${r.toolCalled ? ` ${C.dim}${r.toolCalled}${C.reset}` : ""}`;
console.log(` init ${tick(r.steps.initialize.ok)} tools/list ${tick(r.steps.toolsList.ok)} ${C.dim}(${r.toolCount})${C.reset} tools/call ${callLine}`);
if (r.error) console.log(` ${C.red}${logLine(r.error)}${C.reset}`);
} catch (err) {
record.error = String(err.message || err);
console.log(` ${C.red}ERROR${C.reset} ${logLine(record.error).slice(0, 300)}`);
} finally {
if (record.cleanup) {
try {
await uninstallConnector(config, server.apiName);
console.log(` ${C.dim}cleaned up fresh install${C.reset}`);
} catch (err) {
record.cleanupError = String(err.message || err);
console.log(` ${C.red}CLEANUP ERROR${C.reset} ${logLine(record.cleanupError).slice(0, 300)}`);
}
}
}
results.push(record);
console.log("");
}
// 3. Summary + report files.
const probed = results.filter((r) => r.probe);
const passed = probed.filter((r) => r.probe.status === "passed");
const failed = probed.filter((r) => r.probe.status === "failed");
const safeCallSkipped = probed.filter((r) => r.probe.status === "skipped");
const orchestrationErrors = results.filter((r) => r.error || r.cleanupError);
const needsConsent = results.filter((r) => r.classification === "needs-consent" || r.classification === "pending-consent");
const skipped = [...results.filter((r) => !r.probe && !needsConsent.includes(r)), ...safeCallSkipped];
console.log(`${C.bold}Summary${C.reset}`);
console.log(` probed: ${probed.length}`);
console.log(` ${C.green}passed: ${passed.length}${C.reset}`);
console.log(` ${C.red}failed: ${failed.length}${C.reset}`);
console.log(` ${C.red}errors: ${orchestrationErrors.length}${C.reset}`);
console.log(` ${C.yellow}needs consent: ${needsConsent.length}${C.reset}`);
console.log(` skipped: ${skipped.length}`);
if (needsConsent.length) {
console.log(`\n ${C.yellow}Consent once for these, then re-run to test headless:${C.reset}`);
for (const r of needsConsent) console.log(` - ${r.displayName} (${r.apiName})`);
}
if (!existsSync(REPORTS_DIR)) mkdirSync(REPORTS_DIR, { recursive: true, mode: 0o700 });
chmodSync(REPORTS_DIR, 0o700);
const ts = new Date().toISOString().replace(/[:.]/g, "-");
const jsonPath = join(REPORTS_DIR, `mcp-smoke-${ts}.json`);
const logPath = join(REPORTS_DIR, `mcp-smoke-${ts}.log`);
const report = {
timestamp: new Date().toISOString(),
gateway: { gatewayName: config.gatewayName, resourceGroup: config.resourceGroup },
totals: { probed: probed.length, passed: passed.length, failed: failed.length, errors: orchestrationErrors.length, needsConsent: needsConsent.length, skipped: skipped.length },
servers: results,
};
writeFileSync(jsonPath, redact(JSON.stringify(report, null, 2)), { encoding: "utf-8", mode: 0o600 });
writeFileSync(logPath, redact(renderLog(report)), { encoding: "utf-8", mode: 0o600 });
chmodSync(jsonPath, 0o600);
chmodSync(logPath, 0o600);
console.log(`\n report: ${logPath}`);
console.log(` json: ${jsonPath}`);
// CI-friendly: non-zero if a probe or orchestration step failed.
process.exit(failed.length > 0 || orchestrationErrors.length > 0 ? 1 : 0);
}
function renderLog(report) {
const lines = [];
lines.push(`MCP smoke test ${report.timestamp}`);
lines.push(`gateway: ${report.gateway.gatewayName} rg: ${report.gateway.resourceGroup}`);
lines.push("");
for (const s of report.servers) {
lines.push(`## ${s.displayName} (${s.apiName})`);
lines.push(` classification: ${s.classification}`);
if (s.error) lines.push(` error: ${s.error}`);
if (s.cleanupError) lines.push(` cleanupError: ${s.cleanupError}`);
if (s.probe) {
const p = s.probe;
lines.push(` serverInfo: ${p.serverInfo || "-"}`);
lines.push(` initialize: ${p.steps.initialize.ok ? "PASS" : "FAIL"} (${p.steps.initialize.latencyMs ?? "-"}ms)`);
lines.push(` tools/list: ${p.steps.toolsList.ok ? "PASS" : "FAIL"}${p.toolCount} tools`);
const tc = p.steps.toolsCall;
lines.push(` tools/call: ${tc.status}${p.toolCalled ? ` [${p.toolCalled}, ${p.toolSource}]` : ""}`);
if (tc.error) lines.push(` callError: ${tc.error}`);
if (p.error) lines.push(` probeError: ${p.error}`);
}
lines.push("");
}
const t = report.totals;
lines.push(`SUMMARY probed=${t.probed} passed=${t.passed} failed=${t.failed} errors=${t.errors} needsConsent=${t.needsConsent} skipped=${t.skipped}`);
return lines.join("\n");
}
main().catch((err) => {
console.error(`${C.red}Fatal:${C.reset} ${err.stack || err}`);
process.exit(2);
});