Skip to content
This repository was archived by the owner on Jul 27, 2026. It is now read-only.
Closed
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
29 changes: 29 additions & 0 deletions __tests__/routes/mcp-page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,35 @@ describe("MCPPage", () => {
);
});

it("renders the Azure DevOps marketplace tile and opens its PAT install modal", async () => {
// Issue #929: Azure DevOps must surface in the MCP marketplace, and the
// install modal must offer the locally installable PAT stdio path.
vi.spyOn(SettingsService, "getSettings").mockResolvedValue(buildSettings());

renderPage();

await screen.findByTestId("mcp-marketplace-grid");
expect(
screen.getByTestId("mcp-marketplace-card-azure-devops"),
).toBeInTheDocument();

fireEvent.click(screen.getByTestId("mcp-marketplace-card-azure-devops"));

await waitFor(() => {
expect(screen.getByTestId("mcp-install-modal")).toBeInTheDocument();
});
// The installable option is the local PAT stdio server.
expect(
screen.getByTestId("mcp-install-field-command-readonly"),
).toHaveValue("npx -y @azure-devops/mcp --authentication pat");
expect(
screen.getByTestId("mcp-install-field-organization"),
).toBeInTheDocument();
expect(
screen.getByTestId("mcp-install-field-PERSONAL_ACCESS_TOKEN"),
).toBeInTheDocument();
});

it("opens the install modal when clicking a marketplace tile", async () => {
vi.spyOn(SettingsService, "getSettings").mockResolvedValue(buildSettings());

Expand Down
96 changes: 96 additions & 0 deletions __tests__/utils/mcp-marketplace-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,3 +278,99 @@ describe("GitHub hosted MCP entry", () => {
expect(match?.id).toBe("github");
});
});

describe("Azure DevOps MCP entry", () => {
// Issue #929: Azure DevOps must surface in the MCP marketplace via the
// official Microsoft Azure DevOps MCP Server, with a hosted remote (OAuth)
// connection and a local PAT-based stdio fallback.
const catalog = getMcpMarketplaceCatalog(MCP_MARKETPLACE);
const azureDevOps = catalog.find((e) => e.id === "azure-devops");

it("appears in the MCP marketplace catalog", () => {
expect(azureDevOps).toBeDefined();
expect(azureDevOps!.logoUrl).toMatch(/^https:\/\//);
expect(azureDevOps!.iconBg).toBeTruthy();
});

it("exposes a hosted remote (OAuth) streamable HTTP option", () => {
const remote = azureDevOps!.connectionOptions.find(
(option) => option.id === "remote",
);
expect(remote).toBeDefined();
expect(remote!.provider).toBe("mcp");
expect(remote!.auth.strategy).toBe("oauth2");
expect(remote!.transport?.kind).toBe("shttp");
if (remote!.transport?.kind !== "shttp") {
throw new Error("expected shttp transport");
}
expect(remote!.transport.url).toBe(
"https://mcp.dev.azure.com/{organization}",
);
// The organization segment is account-specific, so the URL is editable.
expect(remote!.transport.urlEditable).toBe(true);
});

it("exposes a locally installable PAT stdio fallback", () => {
const installable = getInstallableMcpConnectionOption(azureDevOps!);
expect(installable?.id).toBe("pat");
expect(installable?.auth.strategy).toBe("api_key");
expect(installable?.transport.kind).toBe("stdio");
if (installable?.transport.kind !== "stdio") {
throw new Error("expected stdio transport");
}
expect(installable.transport.command).toBe("npx");
expect(installable.transport.args).toContain("@azure-devops/mcp");
expect(installable.transport.args).toContain("pat");
// The PAT is supplied via the documented PERSONAL_ACCESS_TOKEN env var.
const envFields = installable.transport.envFields ?? [];
expect(
envFields.some((field) => field.key === "PERSONAL_ACCESS_TOKEN"),
).toBe(true);
// The organization name is passed as a CLI positional argument.
expect(
(installable.transport.argFields ?? []).some(
(field) => field.key === "organization",
),
).toBe(true);
});

it("matches installed local stdio servers by server name", () => {
const pat = azureDevOps!.connectionOptions.find(
(option) => option.id === "pat",
);
const transport = pat?.transport;
if (transport?.kind !== "stdio") {
throw new Error("expected stdio transport for the pat option");
}
const match = findInstalledMatch(transport, [
{
id: "stdio-0",
type: "stdio",
name: "azure-devops",
command: "npx",
args: ["-y", "@azure-devops/mcp", "contoso", "--authentication", "pat"],
},
]);
expect(match).toEqual(expect.objectContaining({ id: "stdio-0" }));
expect(
findCatalogEntryForServer(
{
id: "stdio-0",
type: "stdio",
name: "azure-devops",
command: "npx",
args: [],
},
catalog,
)?.id,
).toBe("azure-devops");
});

it("is searchable by name and keywords", () => {
expect(marketplaceEntryMatchesQuery(azureDevOps!, "azure devops")).toBe(
true,
);
expect(marketplaceEntryMatchesQuery(azureDevOps!, "ado")).toBe(true);
expect(marketplaceEntryMatchesQuery(azureDevOps!, "pipelines")).toBe(true);
});
});
9 changes: 5 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"@heroui/react": "2.8.10",
"@microlink/react-json-view": "1.31.20",
"@monaco-editor/react": "4.7.0",
"@openhands/extensions": "0.7.0",
"@openhands/extensions": "github:OpenHands/extensions#ca4d9ce5fade1de80a661353ff39635f841f4c56",
"@openhands/typescript-client": "1.28.0",
"@react-router/node": "7.17.0",
"@react-router/serve": "7.17.0",
Expand Down
Loading