From f706c0cb055bd63c61399650195457e52cab7a47 Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 1 Jul 2026 17:47:55 +0000 Subject: [PATCH] feat(mcp): surface Azure DevOps in the MCP marketplace Bumps @openhands/extensions to the commit that adds the Azure DevOps integration catalog entry (OpenHands/extensions#384), so an tile appears in the Agent Canvas MCP marketplace. The entry exposes the official Microsoft Azure DevOps MCP Server with a hosted remote (OAuth) streamable HTTP connection and a local PAT-based stdio fallback. The install modal renders the local PAT stdio path () with an organization CLI argument and the PERSONAL_ACCESS_TOKEN env field. Adds unit + route-render tests verifying the tile renders, the install modal opens with the PAT fields, and the entry is searchable / matched against installed servers. Closes #929 Co-authored-by: openhands --- __tests__/routes/mcp-page.test.tsx | 29 ++++++ __tests__/utils/mcp-marketplace-utils.test.ts | 96 +++++++++++++++++++ package-lock.json | 9 +- package.json | 2 +- 4 files changed, 131 insertions(+), 5 deletions(-) diff --git a/__tests__/routes/mcp-page.test.tsx b/__tests__/routes/mcp-page.test.tsx index 6ff20324a..56fb06950 100644 --- a/__tests__/routes/mcp-page.test.tsx +++ b/__tests__/routes/mcp-page.test.tsx @@ -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()); diff --git a/__tests__/utils/mcp-marketplace-utils.test.ts b/__tests__/utils/mcp-marketplace-utils.test.ts index 44438048e..228c0cc2d 100644 --- a/__tests__/utils/mcp-marketplace-utils.test.ts +++ b/__tests__/utils/mcp-marketplace-utils.test.ts @@ -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); + }); +}); diff --git a/package-lock.json b/package-lock.json index 7e34c00e8..e3a4508e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,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", @@ -3469,9 +3469,10 @@ "license": "MIT" }, "node_modules/@openhands/extensions": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@openhands/extensions/-/extensions-0.7.0.tgz", - "integrity": "sha512-p0gY8bBR5Kmyu9I+/YYy0MlvO5D92KQYbocv3/W1Foy/fkcJv92iCultz66Vczpr42lzl+mc0FJ1/xO9/1+JSQ==", + "version": "0.8.0", + "resolved": "git+ssh://git@github.com/OpenHands/extensions.git#ca4d9ce5fade1de80a661353ff39635f841f4c56", + "integrity": "sha512-q/caYGK+eu+ykovOw3aJCsQYgHc2wpfLIHA2KV13CsLhOLCZvQwufCx5/qvBcLXPXFjbkZBGSQ3gXPo86O3tlQ==", + "license": "MIT", "engines": { "node": ">=18.20.0" }, diff --git a/package.json b/package.json index b2a98c537..2eca01c91 100644 --- a/package.json +++ b/package.json @@ -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",