Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -244,8 +244,8 @@
{
"name": "connector-namespaces",
"source": "extensions/connector-namespaces",
"description": "Browse, connect, and open MCP connectors from an Azure Connector Namespace.",
"version": "1.1.2"
"description": "Interactive GitHub Copilot canvas for discovering, connecting, and managing hosted MCP servers from Azure Connector Namespace.",
"version": "1.2.0"
},
{
"name": "context-engineering",
Expand Down
4 changes: 2 additions & 2 deletions extensions/connector-namespaces/.github/plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "connector-namespaces",
"description": "Browse, connect, and open MCP connectors from an Azure Connector Namespace.",
"version": "1.1.2",
"description": "Interactive GitHub Copilot canvas for discovering, connecting, and managing hosted MCP servers from Azure Connector Namespace.",
"version": "1.2.0",
"author": {
"name": "Alex Yang",
"url": "https://github.com/alexyaang"
Expand Down
142 changes: 71 additions & 71 deletions extensions/connector-namespaces/README.md
Original file line number Diff line number Diff line change
@@ -1,84 +1,84 @@
# 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.
# MCP Connectors

A GitHub Copilot app canvas extension for discovering and connecting hosted MCP
servers from [Azure Connector Namespace](https://learn.microsoft.com/en-us/azure/connector-namespace/connector-namespace-overview).
It brings the Microsoft and partner connector catalog, guided browser sign-in,
and connected-server management into the Copilot side panel.

## Features

- **Connector catalog** - browse and search Microsoft and partner MCP servers
available in your namespace.
- **Guided Azure setup** - sign in from the canvas, then choose a subscription
and Connector Namespace.
- **Browser-based connection flow** - complete each connector's authentication
or consent without leaving the setup experience.
- **My MCPs** - see which servers are connected and ready to add to Copilot.
- **Namespace playground** - open any connected server in the Connector
Namespace playground with **Sandbox**.
- **Persistent setup** - retain the selected namespace and restore Azure sign-in
securely across app restarts.

## Install

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

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

For a reproducible install, swap `main` for a reviewed commit SHA from this
repository.
## Requirements

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

- **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.
Connector Namespace is currently an Azure preview service and availability can
vary by region.

## 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.
1. Open the **MCP Connectors** canvas in the GitHub Copilot app.
2. Select **Sign in to Azure** and complete Microsoft Entra authentication in
your browser.
3. Choose an Azure subscription and Connector Namespace.
4. Browse or search the catalog, then select **Connect** on an MCP server.
5. Complete the connector's sign-in or consent flow when prompted.
6. Confirm the server appears under **My MCPs**.
7. Restart the GitHub Copilot app so the new tools become available to the
agent.

Use **Sandbox** on a connected server to inspect it in the Connector Namespace
playground. Use **Change namespace** to switch subscriptions or namespaces.

## Authentication and privacy

Azure sign-in and connector sign-in are separate:

- **Azure sign-in** lets the canvas discover and manage Connector Namespace
resources. Access and refresh tokens are stored in the operating system's
encrypted credential store. To select that encrypted cache entry after an app
restart, the extension separately saves a non-secret authentication record
containing the authority, client ID, account ID, tenant ID, and username under
`~/.copilot/extensions/connector-namespaces/artifacts/azure-auth-record.json`,
with user-only permissions where supported. Raw tokens are never written to
extension files.
- **Connector sign-in** grants an individual MCP server access to its backing
service. The resulting connection is managed by Connector Namespace.

The canvas serves its interface from loopback only (`127.0.0.1`). Azure
management requests are restricted to `https://management.azure.com/`.
The gateway API key that lets Copilot reach a connected server is stored in the
user-scoped GitHub Copilot MCP configuration and sent only to that server's
configured HTTPS endpoint.

## License

Expand Down
123 changes: 12 additions & 111 deletions extensions/connector-namespaces/armClient.mjs
Original file line number Diff line number Diff line change
@@ -1,62 +1,17 @@
// ARM API client — fetches real connector data with Azure CLI credentials.
// ARM API client — fetches real connector data with interactive Azure 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";
import { platform } from "node:os";
import { basename, isAbsolute, join, resolve, sep } from "node:path";
import { getToken } from "./auth.mjs";

export { getToken };

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);
Expand Down Expand Up @@ -92,29 +47,6 @@ async function trustedExecutablePath(path, expectedName, workspaceRoot = process
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)]
Expand All @@ -126,41 +58,6 @@ export async function resolveSystemExecutable(name, workspaceRoot = process.cwd(
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.
*/
Expand All @@ -170,9 +67,13 @@ export async function getToken() {
let s_subsCache = null; // { subs, expiresAt }
const SUBS_TTL_MS = 30 * 60 * 1000;

export async function listSubscriptions() {
export function invalidateSubscriptionsCache() {
s_subsCache = null;
}

export async function listSubscriptions({ forceRefresh = false } = {}) {
const now = Date.now();
if (s_subsCache && s_subsCache.expiresAt > now) return s_subsCache.subs;
if (!forceRefresh && 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);
Expand Down
Loading
Loading