Files
awesome-copilot/extensions/connector-namespaces/test/mcp-probe.mjs
T
Alex Yang [MSFT] 191309165e 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>
2026-07-16 10:29:48 +10:00

174 lines
6.7 KiB
JavaScript

// 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;
}