From d753655bf1bfdb93268f73e4a7d2f8e006c55071 Mon Sep 17 00:00:00 2001 From: nehaprasad-dev Date: Fri, 29 May 2026 21:26:27 +0530 Subject: [PATCH] feat(mcp): support Azure DevOps marketplace install (remote + PAT) Add urlFields, connection options, and installable default transport so the catalog remote and local PAT paths work in the install modal. Co-authored-by: Cursor --- .../constants/extensions-catalogs.test.ts | 27 +++- __tests__/utils/mcp-marketplace-utils.test.ts | 51 +++++++ .../mcp-page/install-server-modal.tsx | 138 ++++++++++++++---- src/utils/mcp-marketplace-utils.ts | 86 +++++++++-- 4 files changed, 263 insertions(+), 39 deletions(-) diff --git a/__tests__/constants/extensions-catalogs.test.ts b/__tests__/constants/extensions-catalogs.test.ts index 967e3431..d62554f0 100644 --- a/__tests__/constants/extensions-catalogs.test.ts +++ b/__tests__/constants/extensions-catalogs.test.ts @@ -32,7 +32,6 @@ describe("OpenHands extensions catalogs", () => { (entry: IntegrationCatalogEntry) => entry.id === "slack", ); expect(slack).toBeDefined(); - // Slack's default is now OAuth/shttp const defaultTemplate = slack ? getDefaultTemplate(slack) : undefined; expect(defaultTemplate?.kind).toBe("shttp"); @@ -57,6 +56,32 @@ describe("OpenHands extensions catalogs", () => { expect(catalogIds.has("tavily")).toBe(true); expect(catalogIds.has("linear")).toBe(true); expect(catalogIds.has("notion")).toBe(true); + expect(catalogIds.has("azure-devops")).toBe(true); + }); + + it("includes Azure DevOps with remote and PAT connection options", () => { + const azureDevOps = INTEGRATION_CATALOG.find( + (entry: IntegrationCatalogEntry) => entry.id === "azure-devops", + ); + expect(azureDevOps).toBeDefined(); + expect(INTEGRATION_LOGOS["azure-devops"]).toBeTruthy(); + expect(azureDevOps?.defaultConnectionOptionId).toBe("remote"); + + const remote = azureDevOps?.connectionOptions.find((o) => o.id === "remote"); + expect(remote?.transport?.kind).toBe("shttp"); + if (remote?.transport?.kind === "shttp") { + expect(remote.transport.url).toBe( + "https://mcp.dev.azure.com/{organization}", + ); + expect(remote.transport.urlFields?.[0]?.key).toBe("organization"); + } + + const pat = azureDevOps?.connectionOptions.find((o) => o.id === "pat"); + expect(pat?.transport?.kind).toBe("stdio"); + if (pat?.transport?.kind === "stdio") { + expect(pat.transport.args).toContain("@azure-devops/mcp"); + expect(pat.transport.suffixArgs).toEqual(["--authentication", "pat"]); + } }); it("loads recommended automations from @openhands/extensions", () => { diff --git a/__tests__/utils/mcp-marketplace-utils.test.ts b/__tests__/utils/mcp-marketplace-utils.test.ts index 2fabacfb..02a37373 100644 --- a/__tests__/utils/mcp-marketplace-utils.test.ts +++ b/__tests__/utils/mcp-marketplace-utils.test.ts @@ -3,9 +3,12 @@ import { findCatalogEntryForServer, findInstalledMatch, getDefaultTemplate, + getInstallableTemplate, installedServerMatchesQuery, isMarketplaceEntryAvailable, marketplaceEntryMatchesQuery, + resolveTransportUrl, + transportUrlsMatch, } from "#/utils/mcp-marketplace-utils"; import { INTEGRATION_CATALOG as INTEGRATION_MARKETPLACE, @@ -215,3 +218,51 @@ describe("findCatalogEntryForServer", () => { expect(match?.id).toBe("atlassian"); }); }); + +describe("transportUrlsMatch", () => { + it("matches parameterized catalog URLs by prefix and suffix", () => { + const templateUrl = "https://mcp.dev.azure.com/{organization}"; + expect( + transportUrlsMatch(templateUrl, "https://mcp.dev.azure.com/contoso"), + ).toBe(true); + expect( + transportUrlsMatch(templateUrl, "https://mcp.dev.azure.com/other-org"), + ).toBe(true); + expect(transportUrlsMatch(templateUrl, "https://example.com/contoso")).toBe( + false, + ); + }); +}); + +describe("resolveTransportUrl", () => { + it("substitutes url field placeholders", () => { + const url = resolveTransportUrl( + { + kind: "shttp", + url: "https://mcp.dev.azure.com/{organization}", + urlFields: [ + { + key: "organization", + label: "Organization name", + required: true, + }, + ], + }, + { organization: "contoso" }, + ); + expect(url).toBe("https://mcp.dev.azure.com/contoso"); + }); +}); + +describe("getInstallableTemplate", () => { + it("prefers the catalog default when it is installable", () => { + const azureDevOps = INTEGRATION_MARKETPLACE.find( + (e: IntegrationCatalogEntry) => e.id === "azure-devops", + )!; + const template = getInstallableTemplate(azureDevOps); + expect(template?.kind).toBe("shttp"); + if (template?.kind === "shttp") { + expect(template.url).toBe("https://mcp.dev.azure.com/{organization}"); + } + }); +}); diff --git a/src/components/features/mcp-page/install-server-modal.tsx b/src/components/features/mcp-page/install-server-modal.tsx index f5136eb8..67ee418c 100644 --- a/src/components/features/mcp-page/install-server-modal.tsx +++ b/src/components/features/mcp-page/install-server-modal.tsx @@ -2,19 +2,27 @@ import React, { useId } from "react"; import { useTranslation } from "react-i18next"; import { AxiosError } from "axios"; import type { MCPTestFailure } from "@openhands/typescript-client"; +import type { + IntegrationConnectionOption, + IntegrationCatalogEntry as MarketplaceEntry, + IntegrationTransport as MarketplaceTemplate, +} from "@openhands/extensions/integrations"; import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; import { ModalCloseButton } from "#/components/shared/modals/modal-close-button"; import { BrandButton } from "#/components/features/settings/brand-button"; import { SettingsInput } from "#/components/features/settings/settings-input"; import { I18nKey } from "#/i18n/declaration"; -import type { IntegrationCatalogEntry as MarketplaceEntry } from "@openhands/extensions/integrations"; import { McpLogoBadge } from "#/components/features/mcp-logo-badge"; import { MCPServerConfig } from "#/types/mcp-server"; import { useAddMcpServer } from "#/hooks/mutation/use-add-mcp-server"; import { useTestMcpServer } from "#/hooks/mutation/use-test-mcp-server"; import { displaySuccessToast } from "#/utils/custom-toast-handlers"; import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message"; -import { getInstallableTemplate } from "#/utils/mcp-marketplace-utils"; +import { + getInstallableConnectionOptions, + getInstallableTemplate, + resolveTransportUrl, +} from "#/utils/mcp-marketplace-utils"; interface InstallServerModalProps { entry: MarketplaceEntry; @@ -27,9 +35,10 @@ interface FieldState { errors: Record; } -function makeInitialState(entry: MarketplaceEntry): FieldState { +function makeInitialState( + template: MarketplaceTemplate | undefined, +): FieldState { const values: Record = {}; - const template = getInstallableTemplate(entry); if (!template) return { values, errors: {} }; if (template.kind === "stdio") { for (const field of template.envFields ?? []) { @@ -40,10 +49,21 @@ function makeInitialState(entry: MarketplaceEntry): FieldState { } } else if (template.kind === "shttp" || template.kind === "sse") { values.api_key = ""; + for (const field of template.urlFields ?? []) { + values[field.key] = ""; + } } return { values, errors: {} }; } +function defaultInstallableOptionId(entry: MarketplaceEntry): string { + const options = getInstallableConnectionOptions(entry); + const preferred = options.find( + (o) => o.id === entry.defaultConnectionOptionId, + ); + return preferred?.id ?? options[0]?.id ?? entry.defaultConnectionOptionId; +} + // The marketplace install modal is intentionally add-only: clicking // a catalog tile always appends a new server (the user might want // two Slack workspaces, two Postgres connections, etc.) even when @@ -60,13 +80,28 @@ export function InstallServerModal({ const { mutate: testMcpServer, isPending: isTesting } = useTestMcpServer(); const instanceId = useId(); + const installableOptions = getInstallableConnectionOptions(entry); + const [selectedOptionId, setSelectedOptionId] = React.useState(() => + defaultInstallableOptionId(entry), + ); + const selectedOption = + installableOptions.find((o) => o.id === selectedOptionId) ?? + installableOptions[0]; + const template = selectedOption?.transport ?? getInstallableTemplate(entry); + const [state, setState] = React.useState(() => - makeInitialState(entry), + makeInitialState(template), ); const [globalError, setGlobalError] = React.useState(null); const isPending = isTesting || isAdding; + const selectConnectionOption = (option: IntegrationConnectionOption) => { + setSelectedOptionId(option.id); + setState(makeInitialState(option.transport)); + setGlobalError(null); + }; + const setValue = (key: string, value: string) => { setState((prev) => ({ values: { ...prev.values, [key]: value }, @@ -91,7 +126,6 @@ export function InstallServerModal({ onSuccess: (result) => { if (!result.ok) { setGlobalError(makeTestErrorMessage(result)); - // Modal stays open — do NOT call onClose. return; } addMcpServer(payload, { @@ -113,31 +147,29 @@ export function InstallServerModal({ }); }; - const template = getInstallableTemplate(entry); - - // ------------------------------------------------------------------ - // Per-template submit handlers. Each is small and self-contained: - // validate user input, build the payload, then hand off to - // submitServer. - // ------------------------------------------------------------------ const handleHttpServerSubmit = () => { - // TS narrows this branch to shttp|sse; the equality guard is a - // runtime/defensive belt to make the helper safe in isolation. if (!template || (template.kind !== "shttp" && template.kind !== "sse")) { return; } + const errors: Record = {}; + for (const field of template.urlFields ?? []) { + if (field.required && !(state.values[field.key] ?? "").trim()) { + errors[field.key] = t(I18nKey.MCP$ERROR_FIELD_REQUIRED); + } + } const apiKey = state.values.api_key?.trim() ?? ""; if (!template.apiKeyOptional && !apiKey) { - setState((prev) => ({ - ...prev, - errors: { api_key: t(I18nKey.MCP$ERROR_FIELD_REQUIRED) }, - })); + errors.api_key = t(I18nKey.MCP$ERROR_FIELD_REQUIRED); + } + if (Object.values(errors).some(Boolean)) { + setState((prev) => ({ ...prev, errors })); return; } + const payload: MCPServerConfig = { id: `${template.kind}-${instanceId}`, type: template.kind, - url: template.url, + url: resolveTransportUrl(template, state.values), ...(apiKey && { api_key: apiKey }), }; submitServer(payload); @@ -172,7 +204,6 @@ export function InstallServerModal({ for (const field of stdio.argFields ?? []) { const v = state.values[field.key]?.trim(); if (v) { - // Filesystem-style multi-token input: split on whitespace. for (const token of v.split(/\s+/)) { if (token) extraArgs.push(token); } @@ -184,7 +215,7 @@ export function InstallServerModal({ type: "stdio", name: stdio.serverName, command: stdio.command, - args: [...stdio.args, ...extraArgs], + args: [...stdio.args, ...extraArgs, ...(stdio.suffixArgs ?? [])], ...(Object.keys(env).length > 0 && { env }), }; submitServer(payload); @@ -199,18 +230,51 @@ export function InstallServerModal({ return handleStdioSubmit(); }; + const resolvedUrl = + template?.kind === "shttp" || template?.kind === "sse" + ? resolveTransportUrl(template, state.values) + : ""; + + const connectionHint = + selectedOption?.installHint ?? + (installableOptions.length === 1 ? entry.installHint : undefined); + const renderFields = () => { if (!template) return null; if (template.kind === "shttp" || template.kind === "sse") { const apiKeyOptional = template.apiKeyOptional ?? false; return ( <> + {(template.urlFields ?? []).map((field) => ( +
+ setValue(field.key, v)} + placeholder={field.placeholder} + required={field.required} + showOptionalTag={!field.required} + className="w-full" + /> + {field.helperText && ( +

{field.helperText}

+ )} + {state.errors[field.key] && ( +

+ {state.errors[field.key]} +

+ )} +
+ ))} {}} isDisabled className="w-full" @@ -318,8 +382,32 @@ export function InstallServerModal({ - {entry.installHint && ( -

{entry.installHint}

+ {installableOptions.length > 1 && ( +
+ {installableOptions.map((option) => ( + + ))} +
+ )} + + {connectionHint && ( +

{connectionHint}

)} {entry.docsUrl && ( diff --git a/src/utils/mcp-marketplace-utils.ts b/src/utils/mcp-marketplace-utils.ts index 0d40ecaa..0b8d3e70 100644 --- a/src/utils/mcp-marketplace-utils.ts +++ b/src/utils/mcp-marketplace-utils.ts @@ -1,6 +1,7 @@ import { MCPServerConfig } from "#/types/mcp-server"; import type { IntegrationCatalogEntry as MarketplaceEntry, + IntegrationConnectionOption, IntegrationTransport as MarketplaceTemplate, } from "@openhands/extensions/integrations"; @@ -55,25 +56,83 @@ export function getDefaultTemplate( return option?.transport; } +export function isInstallableConnectionOption( + option: IntegrationConnectionOption, +): boolean { + const transport = option.transport; + if (!transport) return false; + if (transport.kind === "stdio") return true; + if (transport.kind === "sse" || transport.kind === "shttp") { + return transport.apiKeyOptional === true || option.auth.strategy === "none"; + } + return false; +} + +export function getInstallableConnectionOptions( + entry: MarketplaceEntry, +): IntegrationConnectionOption[] { + return entry.connectionOptions.filter(isInstallableConnectionOption); +} + +export function resolveTransportUrl( + template: Extract, + values: Record, +): string { + let url = template.url; + for (const field of template.urlFields ?? []) { + const value = values[field.key]?.trim() ?? ""; + url = url.replaceAll(`{${field.key}}`, encodeURIComponent(value)); + } + return url; +} + +function transportUrlPattern(templateUrl: string): { + prefix: string; + suffix: string; +} | null { + const start = templateUrl.indexOf("{"); + const end = templateUrl.indexOf("}", start + 1); + if (start < 0 || end < 0) return null; + return { + prefix: templateUrl.slice(0, start), + suffix: templateUrl.slice(end + 1), + }; +} + +export function transportUrlsMatch( + templateUrl: string, + installedUrl: unknown, +): boolean { + const pattern = transportUrlPattern(templateUrl); + if (!pattern) return urlsMatch(templateUrl, installedUrl); + const installed = typeof installedUrl === "string" ? installedUrl.trim() : ""; + if (!installed) return false; + return ( + installed.startsWith(pattern.prefix) && installed.endsWith(pattern.suffix) + ); +} + /** - * Get the stdio (API key-based) transport template from an integration entry. - * Many integrations have multiple connection options (e.g., OAuth + stdio). - * Since OAuth isn't implemented in the UI yet, the install modal should use - * this function to get the stdio-based option that can be configured with - * API keys/tokens. - * - * Falls back to getDefaultTemplate if no stdio option exists. + * Pick the transport template the install modal can configure today: + * the catalog default when it is installable, otherwise the first stdio + * option, otherwise the default transport. */ export function getInstallableTemplate( entry: MarketplaceEntry, ): MarketplaceTemplate | undefined { - // First, try to find a stdio option (API key-based, what we can actually install) + const defaultOption = + entry.connectionOptions.find( + (o) => o.id === entry.defaultConnectionOptionId, + ) ?? entry.connectionOptions[0]; + if (defaultOption && isInstallableConnectionOption(defaultOption)) { + return defaultOption.transport; + } + const stdioOption = entry.connectionOptions.find( (o) => o.transport?.kind === "stdio", ); if (stdioOption?.transport) return stdioOption.transport; - // Fall back to the default template (could be shttp/sse with api_key) return getDefaultTemplate(entry); } @@ -91,7 +150,8 @@ export function findInstalledMatch( if (!tplUrl) return null; return ( servers.find( - (s) => s.type === "shttp" && !!s.url && urlsMatch(s.url, tplUrl), + (s) => + s.type === "shttp" && !!s.url && transportUrlsMatch(tplUrl, s.url), ) ?? null ); } @@ -101,7 +161,7 @@ export function findInstalledMatch( if (!tplUrl) return null; return ( servers.find( - (s) => s.type === "sse" && !!s.url && urlsMatch(s.url, tplUrl), + (s) => s.type === "sse" && !!s.url && transportUrlsMatch(tplUrl, s.url), ) ?? null ); } @@ -227,11 +287,11 @@ export function findCatalogEntryForServer( // render the generic icon while the marketplace shows the // entry as installed, which is confusing. if (tpl.kind === "shttp") { - if (server.type === "shttp" && urlsMatch(server.url, tpl.url)) + if (server.type === "shttp" && transportUrlsMatch(tpl.url, server.url)) return true; } if (tpl.kind === "sse") { - if (server.type === "sse" && urlsMatch(server.url, tpl.url)) + if (server.type === "sse" && transportUrlsMatch(tpl.url, server.url)) return true; } }