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

* Add MCP Connectors (connector-namespaces) canvas extension

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

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

* Update Connector Namespaces canvas extension

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

* Harden Connector Namespaces canvas extension

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

* Sanitize connector icon brand colors

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

* Add Connector Namespace playground actions

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

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

* Replace sandbox skill with native tool

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

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

* Clarify connector disconnect action

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

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

* Color disconnect action red

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

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

* Add connector extension plugin manifest

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

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

* Address connector canvas review feedback

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

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

* Remove legacy connector canvas manifest

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

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

* Address remaining connector review feedback

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

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

* Harden connector review fixes

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

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

* Harden connector convergence

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

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

* Use native HTTP connector configs

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

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

* Secure persisted connector state

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

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

---------

Co-authored-by: Alex Yang <yangalex@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Alex Yang [MSFT]
2026-07-15 17:29:48 -07:00
committed by GitHub
parent e4a1f57fd9
commit 191309165e
35 changed files with 7658 additions and 0 deletions
@@ -0,0 +1,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);
});