mirror of
https://github.com/github/awesome-copilot.git
synced 2026-07-16 02:43:24 +00:00
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:
@@ -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": "."
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user