Skip to content

Commit ecf0f5a

Browse files
authored
Merge pull request #2364 from alexyaang/alexyaang-interactive-connector-sign-in
Add persistent browser auth to connector canvas
2 parents ff8719e + 7de9e4f commit ecf0f5a

17 files changed

Lines changed: 1177 additions & 323 deletions

File tree

.github/plugin/marketplace.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -244,8 +244,8 @@
244244
{
245245
"name": "connector-namespaces",
246246
"source": "extensions/connector-namespaces",
247-
"description": "Browse, connect, and open MCP connectors from an Azure Connector Namespace.",
248-
"version": "1.1.2"
247+
"description": "Interactive GitHub Copilot canvas for discovering, connecting, and managing hosted MCP servers from Azure Connector Namespace.",
248+
"version": "1.2.0"
249249
},
250250
{
251251
"name": "context-engineering",

extensions/connector-namespaces/.github/plugin/plugin.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "connector-namespaces",
3-
"description": "Browse, connect, and open MCP connectors from an Azure Connector Namespace.",
4-
"version": "1.1.2",
3+
"description": "Interactive GitHub Copilot canvas for discovering, connecting, and managing hosted MCP servers from Azure Connector Namespace.",
4+
"version": "1.2.0",
55
"author": {
66
"name": "Alex Yang",
77
"url": "https://github.com/alexyaang"

extensions/connector-namespaces/README.md

Lines changed: 71 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,84 +1,84 @@
1-
# MCP Connectors — Copilot CLI Canvas Extension
2-
3-
A GitHub Copilot CLI **canvas extension** that lets you browse and add MCP
4-
connectors from an Azure **Connector Namespace** directly inside a Copilot CLI
5-
session. Search by name or category, sign in to a connector, then restart the
6-
session to make its tools available to the agent.
7-
8-
> The canvas talks to public Azure Resource Manager (`management.azure.com`)
9-
> using the signed-in Azure CLI account. The extension does not register its own
10-
> Entra application or persist Azure credentials.
11-
12-
## Prerequisites
13-
14-
- **GitHub Copilot CLI** (the host that loads canvas extensions).
15-
- **Azure CLI**, signed in with `az login`. The extension asks Azure CLI for a
16-
short-lived ARM access token and refreshes it through the same broker.
17-
- **An Azure subscription with a Connector Namespace** — resource type
18-
`Microsoft.Web/connectorGateways` (API version `2026-05-01-preview`). This is
19-
a preview resource provider; you must have access to it for the catalog to
20-
load. Without it the extension installs fine but has nothing to show.
1+
# MCP Connectors
2+
3+
A GitHub Copilot app canvas extension for discovering and connecting hosted MCP
4+
servers from [Azure Connector Namespace](https://learn.microsoft.com/en-us/azure/connector-namespace/connector-namespace-overview).
5+
It brings the Microsoft and partner connector catalog, guided browser sign-in,
6+
and connected-server management into the Copilot side panel.
7+
8+
## Features
9+
10+
- **Connector catalog** - browse and search Microsoft and partner MCP servers
11+
available in your namespace.
12+
- **Guided Azure setup** - sign in from the canvas, then choose a subscription
13+
and Connector Namespace.
14+
- **Browser-based connection flow** - complete each connector's authentication
15+
or consent without leaving the setup experience.
16+
- **My MCPs** - see which servers are connected and ready to add to Copilot.
17+
- **Namespace playground** - open any connected server in the Connector
18+
Namespace playground with **Sandbox**.
19+
- **Persistent setup** - retain the selected namespace and restore Azure sign-in
20+
securely across app restarts.
2121

2222
## Install
2323

24-
Install it from the public Awesome Copilot repository:
24+
Open the GitHub Copilot app, go to **Settings > Plugins**, search for
25+
`connector-namespaces`, and select **Install**.
2526

26-
```
27-
install_extension https://github.com/github/awesome-copilot/tree/main/extensions/connector-namespaces
28-
```
27+
You can also open the
28+
[MCP Connectors gallery page](https://awesome-copilot.github.com/extension/connector-namespaces/)
29+
and select **Install in GitHub Copilot app**.
2930

30-
For a reproducible install, swap `main` for a reviewed commit SHA from this
31-
repository.
31+
## Requirements
3232

33-
The destination **scope** is chosen at install time:
33+
- Access to an Azure subscription with a Connector Namespace. If you do not
34+
have one, follow the
35+
[Connector Namespace creation guide](https://learn.microsoft.com/en-us/azure/connector-namespace/create-connector-namespace).
36+
- Permission to view the namespace and create its connections and hosted MCP
37+
server configurations.
38+
- A browser for Microsoft Entra sign-in and connector consent.
39+
- An operating-system secure credential store. Windows and macOS provide one by
40+
default. Linux and WSL require a Secret Service-compatible keyring, such as
41+
GNOME Keyring, with `libsecret` available. Unencrypted token storage is
42+
intentionally disabled.
3443

35-
- **user** (default) — installs globally for you at
36-
`$COPILOT_HOME/extensions/connector-namespaces/`. The usual choice for a
37-
personal tool.
38-
- **project** — installs into the current repo.
39-
- **session** — scoped to a single CLI session.
44+
Connector Namespace is currently an Azure preview service and availability can
45+
vary by region.
4046

4147
## Usage
4248

43-
1. Open the **MCP Connectors** canvas from Copilot CLI.
44-
2. The canvas loads subscriptions from your signed-in Azure CLI account. Pick an
45-
Azure **subscription** and a **Connector Namespace**. The choice is saved for
46-
future sessions (change it any time via **Change namespace**).
47-
3. Browse or filter the connector catalog, then **Connect**. A browser tab
48-
opens for Microsoft sign-in; complete it and the canvas updates on its own.
49-
4. Connected connectors move into **My MCPs**. Use **Sandbox** on a tile to open
50-
that server directly in the namespace MCP playground.
51-
5. Restart the Copilot CLI session so the agent can load the connected tools.
52-
53-
The extension registers the native `connector_namespaces_open_playground` tool,
54-
so GitHub Copilot can open a named connector from **My MCPs** without installing
55-
an additional Agent Skill.
56-
57-
## How it works
58-
59-
- `extension.mjs` — entry point; declares the canvas, `open_sandbox` action, and
60-
native `connector_namespaces_open_playground` tool.
61-
- `server.mjs` — a loopback HTTP server (bound to `127.0.0.1` only) that serves
62-
the canvas UI and the JSON/OAuth endpoints the iframe calls.
63-
- `armClient.mjs` — thin ARM client (token brokered by Azure CLI, public ARM
64-
base only, SSRF-guarded path segments).
65-
- `catalog.mjs` — fetches and curates the connector list for a namespace.
66-
- `install.mjs` — the connect/install pipeline (managed-API connection, consent,
67-
rollback on cancel, and native HTTPS MCP config registration).
68-
- `renderer.mjs` — all canvas HTML/CSS/client JS.
69-
- `sandbox.mjs` — builds namespace playground links and resolves named My MCPs.
70-
- `state.mjs` — saved namespace and connector state.
71-
72-
## Privacy & security
73-
74-
- ARM tokens come from `az account get-access-token`, stay in process memory,
75-
and are never logged or written by the extension. Azure CLI owns sign-in and
76-
credential storage.
77-
- All servers bind to loopback (`127.0.0.1`) and are never exposed externally.
78-
- ARM requests go only to `https://management.azure.com/`; path segments are
79-
validated to prevent SSRF-style host smuggling.
80-
- The minted gateway API key is stored in the selected Copilot MCP config and
81-
sent to its validated HTTPS endpoint as the `X-API-Key` header.
49+
1. Open the **MCP Connectors** canvas in the GitHub Copilot app.
50+
2. Select **Sign in to Azure** and complete Microsoft Entra authentication in
51+
your browser.
52+
3. Choose an Azure subscription and Connector Namespace.
53+
4. Browse or search the catalog, then select **Connect** on an MCP server.
54+
5. Complete the connector's sign-in or consent flow when prompted.
55+
6. Confirm the server appears under **My MCPs**.
56+
7. Restart the GitHub Copilot app so the new tools become available to the
57+
agent.
58+
59+
Use **Sandbox** on a connected server to inspect it in the Connector Namespace
60+
playground. Use **Change namespace** to switch subscriptions or namespaces.
61+
62+
## Authentication and privacy
63+
64+
Azure sign-in and connector sign-in are separate:
65+
66+
- **Azure sign-in** lets the canvas discover and manage Connector Namespace
67+
resources. Access and refresh tokens are stored in the operating system's
68+
encrypted credential store. To select that encrypted cache entry after an app
69+
restart, the extension separately saves a non-secret authentication record
70+
containing the authority, client ID, account ID, tenant ID, and username under
71+
`~/.copilot/extensions/connector-namespaces/artifacts/azure-auth-record.json`,
72+
with user-only permissions where supported. Raw tokens are never written to
73+
extension files.
74+
- **Connector sign-in** grants an individual MCP server access to its backing
75+
service. The resulting connection is managed by Connector Namespace.
76+
77+
The canvas serves its interface from loopback only (`127.0.0.1`). Azure
78+
management requests are restricted to `https://management.azure.com/`.
79+
The gateway API key that lets Copilot reach a connected server is stored in the
80+
user-scoped GitHub Copilot MCP configuration and sent only to that server's
81+
configured HTTPS endpoint.
8282

8383
## License
8484

extensions/connector-namespaces/armClient.mjs

Lines changed: 12 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,17 @@
1-
// ARM API client — fetches real connector data with Azure CLI credentials.
1+
// ARM API client — fetches real connector data with interactive Azure credentials.
22

3-
import { exec, execFile } from "node:child_process";
43
import { constants as fsConstants, promises as fs } from "node:fs";
5-
import { homedir, platform } from "node:os";
6-
import { basename, delimiter, dirname, isAbsolute, join, resolve, sep } from "node:path";
7-
import { promisify } from "node:util";
4+
import { platform } from "node:os";
5+
import { basename, isAbsolute, join, resolve, sep } from "node:path";
6+
import { getToken } from "./auth.mjs";
7+
8+
export { getToken };
89

910
const API_VERSION = "2026-05-01-preview";
1011
const RG_API_VERSION = "2021-04-01";
1112
const MSI_API_VERSION = "2023-01-31";
1213
const SUBS_API_VERSION = "2020-01-01";
1314

14-
const execFileAsync = promisify(execFile);
15-
const execAsync = promisify(exec);
16-
const ARM_RESOURCE = "https://management.azure.com/";
17-
const EXPIRY_SKEW_MS = 5 * 60 * 1000;
18-
const LEGACY_AUTH_CACHE = join(
19-
process.env.COPILOT_HOME || join(homedir(), ".copilot"),
20-
"extensions",
21-
"connector-namespaces",
22-
"artifacts",
23-
"auth-cache.json",
24-
);
25-
26-
let s_auth = null; // { token, expiresAt }
27-
let s_authInFlight = null;
28-
let s_legacyAuthCacheRemoved = false;
29-
30-
export function parseAzureCliToken(stdout) {
31-
let data;
32-
try {
33-
data = JSON.parse(stdout);
34-
} catch {
35-
throw new Error("Azure CLI returned invalid token JSON.");
36-
}
37-
const token = data?.accessToken;
38-
const epochSeconds = Number(data?.expires_on);
39-
const expiresAt = Number.isFinite(epochSeconds) && epochSeconds > 0
40-
? epochSeconds * 1000
41-
: Date.parse(data?.expiresOn);
42-
if (typeof token !== "string" || token.length === 0 || !Number.isFinite(expiresAt)) {
43-
throw new Error("Azure CLI returned an incomplete ARM token.");
44-
}
45-
return { token, expiresAt };
46-
}
47-
48-
async function removeLegacyAuthCache() {
49-
if (s_legacyAuthCacheRemoved) return;
50-
try {
51-
await fs.unlink(LEGACY_AUTH_CACHE);
52-
} catch (error) {
53-
if (error?.code !== "ENOENT") {
54-
throw new Error(`Could not remove the legacy connector credential cache at ${LEGACY_AUTH_CACHE}: ${error.message}`);
55-
}
56-
}
57-
s_legacyAuthCacheRemoved = true;
58-
}
59-
6015
function windowsSystemExecutable(name) {
6116
const systemRoot = process.env.SystemRoot;
6217
if (systemRoot && isAbsolute(systemRoot)) return join(systemRoot, "System32", name);
@@ -92,29 +47,6 @@ async function trustedExecutablePath(path, expectedName, workspaceRoot = process
9247
return candidate;
9348
}
9449

95-
async function resolveWindowsAzureCli() {
96-
const trustedCwd = homedir();
97-
const { stdout } = await execFileAsync(
98-
windowsSystemExecutable("where.exe"),
99-
["az.cmd"],
100-
{ cwd: trustedCwd, encoding: "utf8", windowsHide: true, timeout: 10_000, maxBuffer: 64 * 1024 },
101-
);
102-
for (const path of stdout.split(/\r?\n/).map((line) => line.trim())) {
103-
if (/[%]/.test(path)) continue;
104-
const candidate = await trustedExecutablePath(path, "az.cmd");
105-
if (candidate) return candidate;
106-
}
107-
throw new Error("Azure CLI was not found outside the current workspace.");
108-
}
109-
110-
export async function resolvePosixAzureCli(pathValue = process.env.PATH || "", workspaceRoot = process.cwd()) {
111-
for (const directory of pathValue.split(delimiter)) {
112-
const candidate = await trustedExecutablePath(resolve(directory || workspaceRoot, "az"), "az", workspaceRoot);
113-
if (candidate) return candidate;
114-
}
115-
throw new Error("Azure CLI was not found outside the current workspace.");
116-
}
117-
11850
export async function resolveSystemExecutable(name, workspaceRoot = process.cwd()) {
11951
const candidates = platform() === "win32"
12052
? [windowsSystemExecutable(name)]
@@ -126,41 +58,6 @@ export async function resolveSystemExecutable(name, workspaceRoot = process.cwd(
12658
throw new Error(`Could not resolve the trusted system executable ${name}.`);
12759
}
12860

129-
async function acquireToken() {
130-
await removeLegacyAuthCache();
131-
try {
132-
const windows = platform() === "win32";
133-
const azureCli = windows ? await resolveWindowsAzureCli() : await resolvePosixAzureCli();
134-
const options = { cwd: homedir(), encoding: "utf8", windowsHide: true, timeout: 60_000, maxBuffer: 1024 * 1024 };
135-
const { stdout } = windows
136-
? await execAsync(
137-
`"${azureCli}" account get-access-token --resource https://management.azure.com/ --output json --only-show-errors`,
138-
{ ...options, shell: windowsSystemExecutable("cmd.exe") },
139-
)
140-
: await execFileAsync(
141-
azureCli,
142-
["account", "get-access-token", "--resource", ARM_RESOURCE, "--output", "json", "--only-show-errors"],
143-
options,
144-
);
145-
s_auth = parseAzureCliToken(stdout);
146-
return s_auth.token;
147-
} catch (error) {
148-
const detail = String(error?.stderr || error?.message || "").trim();
149-
throw new Error(
150-
`Azure CLI authentication failed. Install Azure CLI and run "az login" before opening the canvas.${detail ? ` ${detail}` : ""}`,
151-
);
152-
}
153-
}
154-
155-
export async function getToken() {
156-
if (s_auth && s_auth.expiresAt - EXPIRY_SKEW_MS > Date.now()) return s_auth.token;
157-
if (s_authInFlight) return s_authInFlight;
158-
s_authInFlight = acquireToken().finally(() => {
159-
s_authInFlight = null;
160-
});
161-
return s_authInFlight;
162-
}
163-
16461
/**
16562
* List all enabled Azure subscriptions the user has access to.
16663
*/
@@ -170,9 +67,13 @@ export async function getToken() {
17067
let s_subsCache = null; // { subs, expiresAt }
17168
const SUBS_TTL_MS = 30 * 60 * 1000;
17269

173-
export async function listSubscriptions() {
70+
export function invalidateSubscriptionsCache() {
71+
s_subsCache = null;
72+
}
73+
74+
export async function listSubscriptions({ forceRefresh = false } = {}) {
17475
const now = Date.now();
175-
if (s_subsCache && s_subsCache.expiresAt > now) return s_subsCache.subs;
76+
if (!forceRefresh && s_subsCache && s_subsCache.expiresAt > now) return s_subsCache.subs;
17677
const token = await getToken();
17778
const url = `https://management.azure.com/subscriptions?api-version=${SUBS_API_VERSION}`;
17879
const raw = await paginateAll(url, token);

0 commit comments

Comments
 (0)