Skip to content
Open
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
27 changes: 26 additions & 1 deletion __tests__/constants/extensions-catalogs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@
(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");

Expand All @@ -57,6 +56,32 @@
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);

Check failure on line 59 in __tests__/constants/extensions-catalogs.test.ts

View workflow job for this annotation

GitHub Actions / test-and-build (windows)

__tests__/constants/extensions-catalogs.test.ts > OpenHands extensions catalogs > includes common integrations in the catalog

AssertionError: expected false to be true // Object.is equality - Expected + Received - true + false ❯ __tests__/constants/extensions-catalogs.test.ts:59:44
});

it("includes Azure DevOps with remote and PAT connection options", () => {
const azureDevOps = INTEGRATION_CATALOG.find(
(entry: IntegrationCatalogEntry) => entry.id === "azure-devops",
);
expect(azureDevOps).toBeDefined();

Check failure on line 66 in __tests__/constants/extensions-catalogs.test.ts

View workflow job for this annotation

GitHub Actions / test-and-build (windows)

__tests__/constants/extensions-catalogs.test.ts > OpenHands extensions catalogs > includes Azure DevOps with remote and PAT connection options

AssertionError: expected undefined to be defined ❯ __tests__/constants/extensions-catalogs.test.ts:66:25
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");

Check failure on line 76 in __tests__/constants/extensions-catalogs.test.ts

View workflow job for this annotation

GitHub Actions / test-and-build (ubuntu)

Property 'urlFields' does not exist on type '{ kind: "shttp"; url: string; apiKeyOptional?: boolean | undefined; }'.
}

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"]);

Check failure on line 83 in __tests__/constants/extensions-catalogs.test.ts

View workflow job for this annotation

GitHub Actions / test-and-build (ubuntu)

Property 'suffixArgs' does not exist on type '{ kind: "stdio"; serverName: string; command: string; args: string[]; envFields?: MarketplaceField[] | undefined; argFields?: MarketplaceField[] | undefined; }'.
}
});

it("loads recommended automations from @openhands/extensions", () => {
Expand Down
51 changes: 51 additions & 0 deletions __tests__/utils/mcp-marketplace-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@
findCatalogEntryForServer,
findInstalledMatch,
getDefaultTemplate,
getInstallableTemplate,
installedServerMatchesQuery,
isMarketplaceEntryAvailable,
marketplaceEntryMatchesQuery,
resolveTransportUrl,
transportUrlsMatch,
} from "#/utils/mcp-marketplace-utils";
import {
INTEGRATION_CATALOG as INTEGRATION_MARKETPLACE,
Expand Down Expand Up @@ -215,3 +218,51 @@
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: [

Check failure on line 243 in __tests__/utils/mcp-marketplace-utils.test.ts

View workflow job for this annotation

GitHub Actions / test-and-build (ubuntu)

Object literal may only specify known properties, and 'urlFields' does not exist in type '{ kind: "shttp"; url: string; apiKeyOptional?: boolean | undefined; }'.
{
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}");
}
});
});
138 changes: 113 additions & 25 deletions src/components/features/mcp-page/install-server-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,27 @@
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;
Expand All @@ -27,9 +35,10 @@
errors: Record<string, string | null>;
}

function makeInitialState(entry: MarketplaceEntry): FieldState {
function makeInitialState(
template: MarketplaceTemplate | undefined,
): FieldState {
const values: Record<string, string> = {};
const template = getInstallableTemplate(entry);
if (!template) return { values, errors: {} };
if (template.kind === "stdio") {
for (const field of template.envFields ?? []) {
Expand All @@ -40,10 +49,21 @@
}
} else if (template.kind === "shttp" || template.kind === "sse") {
values.api_key = "";
for (const field of template.urlFields ?? []) {

Check failure on line 52 in src/components/features/mcp-page/install-server-modal.tsx

View workflow job for this annotation

GitHub Actions / test-and-build (ubuntu)

Property 'urlFields' does not exist on type '{ kind: "shttp"; url: string; apiKeyOptional?: boolean | undefined; } | { kind: "sse"; url: string; apiKeyOptional?: boolean | undefined; }'.
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
Expand All @@ -60,13 +80,28 @@
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<FieldState>(() =>
makeInitialState(entry),
makeInitialState(template),
);
const [globalError, setGlobalError] = React.useState<string | null>(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 },
Expand All @@ -91,7 +126,6 @@
onSuccess: (result) => {
if (!result.ok) {
setGlobalError(makeTestErrorMessage(result));
// Modal stays open — do NOT call onClose.
return;
}
addMcpServer(payload, {
Expand All @@ -113,31 +147,29 @@
});
};

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<string, string | null> = {};
for (const field of template.urlFields ?? []) {

Check failure on line 155 in src/components/features/mcp-page/install-server-modal.tsx

View workflow job for this annotation

GitHub Actions / test-and-build (ubuntu)

Property 'urlFields' does not exist on type '{ kind: "shttp"; url: string; apiKeyOptional?: boolean | undefined; } | { kind: "sse"; url: string; apiKeyOptional?: boolean | undefined; }'.
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);
Expand Down Expand Up @@ -172,7 +204,6 @@
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);
}
Expand All @@ -184,7 +215,7 @@
type: "stdio",
name: stdio.serverName,
command: stdio.command,
args: [...stdio.args, ...extraArgs],
args: [...stdio.args, ...extraArgs, ...(stdio.suffixArgs ?? [])],

Check failure on line 218 in src/components/features/mcp-page/install-server-modal.tsx

View workflow job for this annotation

GitHub Actions / test-and-build (ubuntu)

Property 'suffixArgs' does not exist on type '{ kind: "stdio"; serverName: string; command: string; args: string[]; envFields?: MarketplaceField[] | undefined; argFields?: MarketplaceField[] | undefined; }'.
...(Object.keys(env).length > 0 && { env }),
};
submitServer(payload);
Expand All @@ -199,18 +230,51 @@
return handleStdioSubmit();
};

const resolvedUrl =
template?.kind === "shttp" || template?.kind === "sse"
? resolveTransportUrl(template, state.values)
: "";

const connectionHint =
selectedOption?.installHint ??

Check failure on line 239 in src/components/features/mcp-page/install-server-modal.tsx

View workflow job for this annotation

GitHub Actions / test-and-build (ubuntu)

Property 'installHint' does not exist on type 'IntegrationConnectionOption'.
(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) => (

Check failure on line 248 in src/components/features/mcp-page/install-server-modal.tsx

View workflow job for this annotation

GitHub Actions / test-and-build (ubuntu)

Parameter 'field' implicitly has an 'any' type.

Check failure on line 248 in src/components/features/mcp-page/install-server-modal.tsx

View workflow job for this annotation

GitHub Actions / test-and-build (ubuntu)

Property 'urlFields' does not exist on type '{ kind: "shttp"; url: string; apiKeyOptional?: boolean | undefined; } | { kind: "sse"; url: string; apiKeyOptional?: boolean | undefined; }'.
<div key={field.key} className="flex flex-col gap-1">
<SettingsInput
testId={`mcp-install-field-${field.key}`}
name={field.key}
type={field.type === "password" ? "password" : "text"}
label={field.label}
value={state.values[field.key] ?? ""}
onChange={(v) => setValue(field.key, v)}
placeholder={field.placeholder}
required={field.required}
showOptionalTag={!field.required}
className="w-full"
/>
{field.helperText && (
<p className="text-xs text-tertiary-alt">{field.helperText}</p>
)}
{state.errors[field.key] && (
<p className="text-xs text-red-500">
{state.errors[field.key]}
</p>
)}
</div>
))}
<SettingsInput
testId="mcp-install-field-url"
name="url"
type="url"
label={t(I18nKey.SETTINGS$MCP_URL)}
value={template.url}
value={resolvedUrl || template.url}
onChange={() => {}}
isDisabled
className="w-full"
Expand Down Expand Up @@ -318,8 +382,32 @@
</div>
</div>

{entry.installHint && (
<p className="text-xs text-tertiary-light">{entry.installHint}</p>
{installableOptions.length > 1 && (
<div
className="flex flex-col gap-2"
data-testid="mcp-install-connection-options"
>
{installableOptions.map((option) => (
<label
key={option.id}
className="flex cursor-pointer items-center gap-2 text-sm text-foreground"
>
<input
type="radio"
name={`${instanceId}-connection-option`}
value={option.id}
checked={selectedOptionId === option.id}
onChange={() => selectConnectionOption(option)}
data-testid={`mcp-install-connection-${option.id}`}
/>
<span>{option.label ?? option.id}</span>

Check failure on line 403 in src/components/features/mcp-page/install-server-modal.tsx

View workflow job for this annotation

GitHub Actions / test-and-build (ubuntu)

Property 'label' does not exist on type 'IntegrationConnectionOption'.
</label>
))}
</div>
)}

{connectionHint && (
<p className="text-xs text-tertiary-light">{connectionHint}</p>
)}

{entry.docsUrl && (
Expand Down
Loading
Loading