Use unified extensions integrations catalog (#757)

* Use unified integrations catalog

* chore: pin extensions to merged integrations commit

* docs: clarify local MCP install selection

* fix: revert @openhands/extensions to working commit for Docker build

The extensions package at b8c1869e does not export ./integrations under
the 'production' Vite condition. Revert to the previously-verified commit
c4d8d972 which passes the Docker build.

Co-authored-by: openhands <openhands@all-hands.dev>

* fix: update test entries to connectionOptions format post-merge

Three test entries in install-server-modal.test.tsx still used the
pre-catalog template: field that no longer exists on IntegrationCatalogEntry.
Convert them to the connectionOptions: [...] format that matches the current
extensions package type.

Co-authored-by: openhands <openhands@all-hands.dev>

* test: add MCP install evidence screenshot

Captured from the real Agent Canvas dev stack running this branch on port 12000 after installing the no-credential Everything (demo) MCP integration through the UI.

Co-authored-by: openhands <openhands@all-hands.dev>

* test: mock MCP preflight in snapshots

Ensure MCP page snapshot flows do not hit a real agent-server /api/mcp/test endpoint when adding marketplace or custom MCP servers in mocked Playwright runs.

Co-authored-by: openhands <openhands@all-hands.dev>

* test: relax home snapshot wait

Allow the home-screen assertion to wait as long as the surrounding snapshot setup under CI load.

Co-authored-by: openhands <openhands@all-hands.dev>

* evidence: full MCP marketplace screenshot showing all 43 catalog entries

* evidence: catalog verification showing all 43 MCP entries from INTEGRATION_CATALOG

---------

Co-authored-by: neubig <398875+neubig@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Graham Neubig
2026-05-31 13:53:29 -04:00
committed by GitHub
co-authored by openhands neubig
parent 95b6d7a23e
commit 6cb2f05aef
22 changed files with 521 additions and 328 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

+6 -2
View File
@@ -293,7 +293,11 @@ import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
const { t } = useTranslation("openhands");
return <button aria-label={t(I18nKey.CHAT$DISMISS_LABEL)}>{t(I18nKey.CHAT$DISMISS)}</button>;
return (
<button aria-label={t(I18nKey.CHAT$DISMISS_LABEL)}>
{t(I18nKey.CHAT$DISMISS)}
</button>
);
// WRONG -- ships English to every locale; flagged by i18next/no-literal-string
return <button aria-label="Dismiss">Dismiss</button>;
@@ -490,7 +494,7 @@ When adding code that needs a new string, decide up front which rule it falls un
- Custom secrets are NOT auto-attached by the agent-server. `POST /api/conversations` only persists what the client sends in `request.secrets`; the persisted secrets store (`/api/settings/secrets`) is never read at conversation-start. `buildStartConversationRequestWithEncryptedSettings` enumerates `SecretsService.getSecrets()` and turns each entry into a `LookupSecret` whose `url` points back at `/api/settings/secrets/{name}` and whose `headers` carry `X-Session-API-Key` for auth. Pre-1.21.x agent-server SDKs would silently drop that header during validation when `secrets_encrypted=true` (the cipher in the validation context tried to `cipher.decrypt(plaintext_session_key)`, failed, and the validator removed the header — the conversation runtime then got 401s for every saved secret). The SDK fix preserves plaintext header values when decryption fails; if you still see saved secrets unavailable inside a conversation, verify the running agent-server bundles a `LookupSecret._validate_secrets` that falls back to plaintext on decrypt failure.
- MCP page layout: MCP is a **top-level** nav entry at `/mcp` (rendered by `src/routes/mcp.tsx`), shown right below "Skills" in `src/components/features/sidebar/sidebar.tsx`. The legacy `/settings/mcp` route still works as a redirect via `src/routes/mcp-settings-redirect.tsx`, and `src/routes/mcp-settings.tsx` re-exports the new page so the published `MCPSettings` library symbol (in `src/components/settings/index.ts`) keeps the same shape. Marketplace catalog data and MCP logo mappings live in `@openhands/extensions/mcps`; the Slack catalog entry there should point at `https://github.com/zencoderai/slack-mcp-server` and use `@zencoderai/slack-mcp-server`. Deprecated marketplace entries removed upstream (for example GitLab / Google Maps / Postgres / Puppeteer / SQLite) should disappear from the marketplace grid. The Installed section still needs to render and search arbitrary non-catalog custom servers via the raw server `name` / `command` fallback in `src/utils/mcp-marketplace-utils.ts` + `InstalledServerCard`. Tavily is a regular stdio MCP entry (`tavily-mcp` + `TAVILY_API_KEY`), not a special built-in sentinel anymore. Components are colocated under `src/components/features/mcp-page/` and reuse the existing `MCPServerForm` for the "Add custom server" / edit flow.
- MCP page layout: MCP is a **top-level** nav entry at `/mcp` (rendered by `src/routes/mcp.tsx`), shown right below "Skills" in `src/components/features/sidebar/sidebar.tsx`. The legacy `/settings/mcp` route still works as a redirect via `src/routes/mcp-settings-redirect.tsx`, and `src/routes/mcp-settings.tsx` re-exports the new page so the published `MCPSettings` library symbol (in `src/components/settings/index.ts`) keeps the same shape. Marketplace catalog data and MCP logo mappings live in the MCP-capable entries from `@openhands/extensions/integrations`; the Slack API catalog option should point at `https://github.com/zencoderai/slack-mcp-server` and use `@zencoderai/slack-mcp-server`. Deprecated marketplace entries removed upstream (for example GitLab / Google Maps / Postgres / Puppeteer / SQLite) should disappear from the marketplace grid. The Installed section still needs to render and search arbitrary non-catalog custom servers via the raw server `name` / `command` fallback in `src/utils/mcp-marketplace-utils.ts` + `InstalledServerCard`. Tavily is a regular stdio MCP entry (`tavily-mcp` + `TAVILY_API_KEY`), not a special built-in sentinel anymore. Components are colocated under `src/components/features/mcp-page/` and reuse the existing `MCPServerForm` for the "Add custom server" / edit flow.
- Library packaging notes:
- Public npm entrypoints now come from `src/index.ts` → `src/lib/index.ts`, with domain barrels under `src/components/{conversation,terminal,browser,files,settings,sidebar}/index.ts`.
@@ -292,10 +292,10 @@ describe("recommended automations", () => {
);
expect(plusBadge.tagName).toBe("SPAN");
expect(plusBadge).toHaveAttribute("aria-hidden", "true");
expect(plusBadge.className).toContain("hover:bg-[var(--oh-interactive-hover)]");
expect(
plusBadge.querySelector('[role="switch"]'),
).not.toBeInTheDocument();
expect(plusBadge.className).toContain(
"hover:bg-[var(--oh-interactive-hover)]",
);
expect(plusBadge.querySelector('[role="switch"]')).not.toBeInTheDocument();
});
it("selects a recommendation directly from its card", () => {
@@ -327,9 +327,16 @@ describe("recommended automations", () => {
const modal = await screen.findByTestId("mcp-install-modal");
expect(modal).toHaveAttribute("data-marketplace-id", "github");
expect(
screen.getByTestId("mcp-install-field-command-readonly"),
).toHaveValue(
"docker run -i --rm -e GITHUB_PERSONAL_ACCESS_TOKEN ghcr.io/github/github-mcp-server",
);
expect(
screen.getByTestId("mcp-install-field-GITHUB_PERSONAL_ACCESS_TOKEN"),
).toBeInTheDocument();
expect(screen.queryByTestId("mcp-install-field-url")).toBeNull();
expect(screen.queryByTestId("mcp-install-field-api_key")).toBeNull();
expect(mockCreateConversationMutate).not.toHaveBeenCalled();
});
@@ -398,9 +405,7 @@ describe("recommended automations", () => {
fireEvent.change(
screen.getByTestId("mcp-install-field-GITHUB_PERSONAL_ACCESS_TOKEN"),
{
target: { value: "github-token" },
},
{ target: { value: "github-token" } },
);
fireEvent.click(screen.getByTestId("mcp-install-submit"));
@@ -7,7 +7,7 @@ import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import { ActiveBackendProvider } from "#/contexts/active-backend-context";
import { InstallServerModal } from "#/components/features/mcp-page/install-server-modal";
import {
INTEGRATION_CATALOG as INTEGRATION_MARKETPLACE,
INTEGRATION_CATALOG as MCP_MARKETPLACE,
type IntegrationCatalogEntry as MarketplaceEntry,
} from "@openhands/extensions/integrations";
@@ -38,18 +38,14 @@ describe("InstallServerModal", () => {
});
});
it("requires Tavily API key and posts a stdio mcp_config diff", async () => {
// Tavily is a stdio-only integration with a single envField.
// Slack now defaults to OAuth/shttp, so we test stdio installs with Tavily.
const tavily = INTEGRATION_MARKETPLACE.find(
(e: MarketplaceEntry) => e.id === "tavily",
)!;
it("uses Slack's API fallback when the default option is OAuth", async () => {
const slack = MCP_MARKETPLACE.find((e) => e.id === "slack")!;
const saveSpy = vi
.spyOn(SettingsService, "saveSettings")
.mockResolvedValue(true);
const onClose = vi.fn();
renderWith(<InstallServerModal entry={tavily} onClose={onClose} />);
renderWith(<InstallServerModal entry={slack} onClose={onClose} />);
await screen.findByTestId("mcp-install-modal");
@@ -59,8 +55,11 @@ describe("InstallServerModal", () => {
expect(saveSpy).not.toHaveBeenCalled();
});
fireEvent.change(screen.getByTestId("mcp-install-field-TAVILY_API_KEY"), {
target: { value: "tvly-test-key" },
fireEvent.change(screen.getByTestId("mcp-install-field-SLACK_BOT_TOKEN"), {
target: { value: "xoxb-abc" },
});
fireEvent.change(screen.getByTestId("mcp-install-field-SLACK_TEAM_ID"), {
target: { value: "T01" },
});
fireEvent.click(screen.getByTestId("mcp-install-submit"));
@@ -71,10 +70,51 @@ describe("InstallServerModal", () => {
mcp_config: { mcpServers: Record<string, unknown> };
};
expect(sentMcpConfig.mcp_config.mcpServers).toMatchObject({
slack: {
command: "npx",
args: ["-y", "@zencoderai/slack-mcp-server"],
env: { SLACK_BOT_TOKEN: "xoxb-abc", SLACK_TEAM_ID: "T01" },
},
});
expect(onClose).toHaveBeenCalled();
});
it("installs Tavily as a stdio MCP server with TAVILY_API_KEY env", async () => {
// Tavily was previously a fake `kind: "tavily-builtin"` template
// that called saveSettings({ search_api_key }) — but that field
// was dropped on the floor in both local and cloud save paths, so
// installing Tavily silently did nothing. It's now a regular
// stdio MCP entry (`npx -y tavily-mcp` + TAVILY_API_KEY) that
// goes through the same mcp_config write as every other entry.
const tavily = MCP_MARKETPLACE.find((e) => e.id === "tavily")!;
const saveSpy = vi
.spyOn(SettingsService, "saveSettings")
.mockResolvedValue(true);
const onClose = vi.fn();
renderWith(<InstallServerModal entry={tavily} onClose={onClose} />);
await screen.findByTestId("mcp-install-modal");
// Submit with no key fails the required-field check.
fireEvent.click(screen.getByTestId("mcp-install-submit"));
await waitFor(() => expect(saveSpy).not.toHaveBeenCalled());
fireEvent.change(screen.getByTestId("mcp-install-field-TAVILY_API_KEY"), {
target: { value: "tvly-secret" },
});
fireEvent.click(screen.getByTestId("mcp-install-submit"));
await waitFor(() => expect(saveSpy).toHaveBeenCalledTimes(1));
const sent = (saveSpy.mock.calls[0][0] as Record<string, unknown>)
.agent_settings_diff as {
mcp_config: { mcpServers: Record<string, unknown> };
};
expect(sent.mcp_config.mcpServers).toMatchObject({
tavily: {
command: "npx",
args: ["-y", "tavily-mcp"],
env: { TAVILY_API_KEY: "tvly-test-key" },
env: { TAVILY_API_KEY: "tvly-secret" },
},
});
expect(onClose).toHaveBeenCalled();
@@ -90,6 +130,7 @@ describe("InstallServerModal", () => {
name: "Synthetic",
description: "Synthetic catalog entry used in tests.",
iconBg: "#000000",
defaultConnectionOptionId: "api",
connectionOptions: [
{
id: "api",
@@ -132,6 +173,7 @@ describe("InstallServerModal", () => {
name: "Synthetic Optional",
description: "Synthetic entry that allows empty api_key.",
iconBg: "#000000",
defaultConnectionOptionId: "api",
connectionOptions: [
{
id: "api",
@@ -141,7 +183,7 @@ describe("InstallServerModal", () => {
url: "https://example.com/mcp",
apiKeyOptional: true,
},
auth: { strategy: "api_key" },
auth: { strategy: "api_key", apiKeyOptional: true },
},
],
};
@@ -166,10 +208,8 @@ describe("InstallServerModal", () => {
it("closes from the top-right close button", async () => {
const onClose = vi.fn();
const tavily = INTEGRATION_MARKETPLACE.find(
(e: MarketplaceEntry) => e.id === "tavily",
)!;
renderWith(<InstallServerModal entry={tavily} onClose={onClose} />);
const slack = MCP_MARKETPLACE.find((e) => e.id === "slack")!;
renderWith(<InstallServerModal entry={slack} onClose={onClose} />);
await screen.findByTestId("mcp-install-modal");
fireEvent.click(screen.getByTestId("mcp-install-modal-close"));
@@ -178,10 +218,8 @@ describe("InstallServerModal", () => {
it("places Cancel before Install in the footer so the dominant action is the last focusable button", async () => {
// Arrange: render with any marketplace entry so the footer is mounted.
const tavily = INTEGRATION_MARKETPLACE.find(
(e: MarketplaceEntry) => e.id === "tavily",
)!;
renderWith(<InstallServerModal entry={tavily} onClose={vi.fn()} />);
const slack = MCP_MARKETPLACE.find((e) => e.id === "slack")!;
renderWith(<InstallServerModal entry={slack} onClose={vi.fn()} />);
await screen.findByTestId("mcp-install-modal");
// Act: locate both footer buttons.
@@ -212,6 +250,7 @@ describe("InstallServerModal", () => {
name: "Failing Server",
description: "Always fails the connection test.",
iconBg: "#000000",
defaultConnectionOptionId: "api",
connectionOptions: [
{
id: "api",
@@ -221,7 +260,7 @@ describe("InstallServerModal", () => {
url: "https://example.com/mcp",
apiKeyOptional: true,
},
auth: { strategy: "api_key" },
auth: { strategy: "api_key", apiKeyOptional: true },
},
],
};
@@ -265,6 +304,7 @@ describe("InstallServerModal", () => {
name: "Passing Server",
description: "Always passes the connection test.",
iconBg: "#000000",
defaultConnectionOptionId: "api",
connectionOptions: [
{
id: "api",
@@ -274,7 +314,7 @@ describe("InstallServerModal", () => {
url: "https://example.com/mcp",
apiKeyOptional: true,
},
auth: { strategy: "api_key" },
auth: { strategy: "api_key", apiKeyOptional: true },
},
],
};
@@ -307,6 +347,7 @@ describe("InstallServerModal", () => {
name: "Pending Server",
description: "Connection test never resolves.",
iconBg: "#000000",
defaultConnectionOptionId: "api",
connectionOptions: [
{
id: "api",
@@ -316,7 +357,7 @@ describe("InstallServerModal", () => {
url: "https://example.com/mcp",
apiKeyOptional: true,
},
auth: { strategy: "api_key" },
auth: { strategy: "api_key", apiKeyOptional: true },
},
],
};
@@ -1,17 +1,12 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import {
INTEGRATION_CATALOG,
type IntegrationCatalogEntry,
} from "@openhands/extensions/integrations";
import { INTEGRATION_CATALOG } from "@openhands/extensions/integrations";
import { McpLogoStackBadge } from "#/components/features/mcp-page/mcp-logo-stack-badge";
function entry(id: string) {
const match = INTEGRATION_CATALOG.find(
(item: IntegrationCatalogEntry) => item.id === id,
);
const match = INTEGRATION_CATALOG.find((item) => item.id === id);
if (!match) {
throw new Error(`Missing integration catalog entry: ${id}`);
throw new Error(`Missing MCP catalog entry: ${id}`);
}
return match;
}
+32 -49
View File
@@ -1,76 +1,59 @@
import { describe, expect, it } from "vitest";
import { AUTOMATION_CATALOG } from "@openhands/extensions/automations";
import { INTEGRATION_LOGOS } from "@openhands/extensions/integrations/logos";
import { INTEGRATION_CATALOG } from "@openhands/extensions/integrations";
import {
INTEGRATION_LOGO_IDS,
INTEGRATION_LOGOS,
} from "@openhands/extensions/integrations/logos";
import {
INTEGRATION_CATALOG,
type IntegrationCatalogEntry,
} from "@openhands/extensions/integrations";
import { getDefaultTemplate } from "#/utils/mcp-marketplace-utils";
getDefaultMcpTransport,
getMcpMarketplaceCatalog,
} from "#/utils/mcp-marketplace-utils";
describe("OpenHands extensions catalogs", () => {
it("hydrates the integration marketplace from @openhands/extensions", () => {
it("hydrates the MCP marketplace from @openhands/extensions", () => {
expect(INTEGRATION_CATALOG.length).toBeGreaterThan(0);
// Tavily has stdio as its default MCP transport
const tavily = INTEGRATION_CATALOG.find(
(entry: IntegrationCatalogEntry) => entry.id === "tavily",
);
const tavilyTemplate = tavily ? getDefaultTemplate(tavily) : undefined;
expect(tavilyTemplate?.kind).toBe("stdio");
expect(INTEGRATION_LOGOS.tavily).toBeTruthy();
// Not all integrations have logos (some may be HTTP-only), so we just
// check that the logo collection is populated
expect(INTEGRATION_LOGO_IDS.size).toBeGreaterThan(0);
const github = INTEGRATION_CATALOG.find((entry) => entry.id === "github");
expect(getDefaultMcpTransport(github!)?.kind).toBe("shttp");
expect(INTEGRATION_LOGOS.github).toBeTruthy();
});
it("includes Slack with OAuth as default and stdio fallback", () => {
const slack = INTEGRATION_CATALOG.find(
(entry: IntegrationCatalogEntry) => entry.id === "slack",
it("patches Slack to the maintained docs and npm package", () => {
const slack = INTEGRATION_CATALOG.find((entry) => entry.id === "slack");
expect(slack?.docsUrl).toBe(
"https://github.com/zencoderai/slack-mcp-server",
);
expect(slack).toBeDefined();
// Slack's default is now OAuth/shttp
const defaultTemplate = slack ? getDefaultTemplate(slack) : undefined;
expect(defaultTemplate?.kind).toBe("shttp");
// But the stdio option with the maintained npm package is still available
const stdioOption = slack?.connectionOptions.find((o) => o.id === "api");
expect(stdioOption?.transport?.kind).toBe("stdio");
if (stdioOption?.transport?.kind === "stdio") {
expect(stdioOption.transport.args).toContain(
"@zencoderai/slack-mcp-server",
);
const apiOption = slack?.connectionOptions.find(
(option) => option.id === "api" && option.transport?.kind === "stdio",
);
expect(apiOption?.transport?.kind).toBe("stdio");
if (apiOption?.transport?.kind !== "stdio") {
throw new Error("Slack API option should be stdio");
}
expect(apiOption.transport.args).toContain("@zencoderai/slack-mcp-server");
expect(apiOption.transport.args).not.toContain(
"@modelcontextprotocol/server-slack",
);
});
it("includes common integrations in the catalog", () => {
it("drops deprecated MCP entries that no longer have maintained replacements", () => {
const catalogIds = new Set(
INTEGRATION_CATALOG.map((entry: IntegrationCatalogEntry) => entry.id),
getMcpMarketplaceCatalog(INTEGRATION_CATALOG).map((entry) => entry.id),
);
// These should all be present
expect(catalogIds.has("github")).toBe(true);
expect(catalogIds.has("slack")).toBe(true);
expect(catalogIds.has("tavily")).toBe(true);
expect(catalogIds.has("linear")).toBe(true);
expect(catalogIds.has("notion")).toBe(true);
expect(catalogIds.has("gitlab")).toBe(false);
expect(catalogIds.has("google-maps")).toBe(false);
expect(catalogIds.has("postgres")).toBe(false);
expect(catalogIds.has("puppeteer")).toBe(false);
expect(catalogIds.has("sqlite")).toBe(false);
});
it("loads recommended automations from @openhands/extensions", () => {
expect(AUTOMATION_CATALOG.length).toBeGreaterThan(0);
const knownIntegrationIds = new Set(
INTEGRATION_CATALOG.map((entry: IntegrationCatalogEntry) => entry.id),
);
const knownMcpIds = new Set(INTEGRATION_CATALOG.map((entry) => entry.id));
for (const automation of AUTOMATION_CATALOG) {
expect(automation.requiredIntegrationIds.length).toBeGreaterThan(0);
expect(
automation.requiredIntegrationIds.every((id: string) =>
knownIntegrationIds.has(id),
),
automation.requiredIntegrationIds.every((id) => knownMcpIds.has(id)),
).toBe(true);
}
});
+25 -11
View File
@@ -1,5 +1,11 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, within, fireEvent, waitFor } from "@testing-library/react";
import {
render,
screen,
within,
fireEvent,
waitFor,
} from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import MCPPage from "#/routes/mcp";
import SettingsService from "#/api/settings-service/settings-service.api";
@@ -88,12 +94,17 @@ describe("MCPPage", () => {
await waitFor(() => {
expect(screen.getByTestId("mcp-install-modal")).toBeInTheDocument();
});
expect(
screen.getByTestId("mcp-install-field-command-readonly"),
).toHaveValue("npx -y @zencoderai/slack-mcp-server");
expect(
screen.getByTestId("mcp-install-field-SLACK_BOT_TOKEN"),
).toBeInTheDocument();
expect(
screen.getByTestId("mcp-install-field-SLACK_TEAM_ID"),
).toBeInTheDocument();
expect(screen.queryByTestId("mcp-install-field-url")).toBeNull();
expect(screen.queryByTestId("mcp-install-field-api_key")).toBeNull();
});
it("filters marketplace tiles by the search input", async () => {
@@ -182,7 +193,9 @@ describe("MCPPage", () => {
fireEvent.click(screen.getByTestId("mcp-section-filter-library"));
await waitFor(() => {
expect(screen.queryByTestId("mcp-installed-empty")).not.toBeInTheDocument();
expect(
screen.queryByTestId("mcp-installed-empty"),
).not.toBeInTheDocument();
});
expect(screen.getByTestId("mcp-marketplace-section")).toBeInTheDocument();
});
@@ -330,9 +343,10 @@ describe("MCPPage", () => {
renderPage();
await screen.findByTestId("mcp-marketplace-card-tavily");
expect(
screen.getByTestId("mcp-marketplace-toggle-tavily"),
).toHaveAttribute("aria-checked", "false");
expect(screen.getByTestId("mcp-marketplace-toggle-tavily")).toHaveAttribute(
"aria-checked",
"false",
);
expect(screen.getByTestId("mcp-installed-list")).toBeInTheDocument();
});
@@ -366,9 +380,10 @@ describe("MCPPage", () => {
renderPage();
const tile = await screen.findByTestId("mcp-marketplace-card-slack");
expect(
screen.getByTestId("mcp-marketplace-toggle-slack"),
).toHaveAttribute("aria-checked", "false");
expect(screen.getByTestId("mcp-marketplace-toggle-slack")).toHaveAttribute(
"aria-checked",
"false",
);
fireEvent.click(tile);
await screen.findByTestId("mcp-install-modal");
@@ -390,9 +405,8 @@ describe("MCPPage", () => {
.agent_settings_diff as {
mcp_config: { mcpServers: Record<string, unknown> };
};
// The original Slack entry is preserved AND a second one is added
// alongside it (suffix-collided name comes from the per-base
// uniqueness logic in toSdkMcpConfig).
// The original Slack stdio entry is preserved and the new stdio
// install is suffixed rather than overwriting it.
expect(Object.keys(sent.mcp_config.mcpServers).sort()).toEqual([
"slack",
"slack_1",
+93 -54
View File
@@ -2,46 +2,45 @@ import { describe, expect, it } from "vitest";
import {
findCatalogEntryForServer,
findInstalledMatch,
getDefaultTemplate,
getDefaultMcpTransport,
getInstallableMcpConnectionOption,
getMcpMarketplaceCatalog,
installedServerMatchesQuery,
isMarketplaceEntryAvailable,
marketplaceEntryMatchesQuery,
} from "#/utils/mcp-marketplace-utils";
import {
INTEGRATION_CATALOG as INTEGRATION_MARKETPLACE,
type IntegrationCatalogEntry,
} from "@openhands/extensions/integrations";
import { INTEGRATION_CATALOG as MCP_MARKETPLACE } from "@openhands/extensions/integrations";
const tavilyEntry = INTEGRATION_MARKETPLACE.find(
(e: IntegrationCatalogEntry) => e.id === "tavily",
)!;
const filesystemEntry = INTEGRATION_MARKETPLACE.find(
(e: IntegrationCatalogEntry) => e.id === "filesystem",
)!;
// Atlassian has an SSE server as the default MCP option
const atlassianEntry = INTEGRATION_MARKETPLACE.find(
(e: IntegrationCatalogEntry) => e.id === "atlassian",
)!;
const mcpMarketplace = getMcpMarketplaceCatalog(MCP_MARKETPLACE);
const slackEntry = mcpMarketplace.find((e) => e.id === "slack")!;
const tavilyEntry = mcpMarketplace.find((e) => e.id === "tavily")!;
const linearEntry = mcpMarketplace.find((e) => e.id === "linear")!;
const filesystemEntry = mcpMarketplace.find((e) => e.id === "filesystem")!;
const tavilyTemplate = getDefaultTemplate(tavilyEntry)!;
const atlassianTemplate = getDefaultTemplate(atlassianEntry)!;
function optionTransport(entry: typeof slackEntry, optionId = "api") {
const transport = entry.connectionOptions.find(
(option) => option.id === optionId,
)?.transport;
if (!transport) throw new Error(`Missing ${optionId} transport`);
return transport;
}
describe("findInstalledMatch", () => {
it("matches stdio servers by name", () => {
const result = findInstalledMatch(tavilyTemplate, [
const result = findInstalledMatch(optionTransport(slackEntry), [
{
id: "stdio-0",
type: "stdio",
name: "tavily",
name: "slack",
command: "npx",
args: ["-y", "tavily-mcp"],
args: ["-y", "@zencoderai/slack-mcp-server"],
},
]);
expect(result).toEqual(expect.objectContaining({ id: "stdio-0" }));
});
it("does not match a different stdio name", () => {
const result = findInstalledMatch(tavilyTemplate, [
const result = findInstalledMatch(optionTransport(slackEntry), [
{
id: "stdio-0",
type: "stdio",
@@ -57,7 +56,7 @@ describe("findInstalledMatch", () => {
// Tavily lives in the catalog as a stdio MCP entry (the previous
// tavily-builtin / search_api_key flow never persisted anywhere
// and silently dropped the key); confirm the now-uniform match.
const result = findInstalledMatch(tavilyTemplate, [
const result = findInstalledMatch(getDefaultMcpTransport(tavilyEntry)!, [
{
id: "stdio-0",
type: "stdio",
@@ -71,19 +70,18 @@ describe("findInstalledMatch", () => {
});
it("matches SSE servers loosely on URL", () => {
// Atlassian has SSE as its default MCP transport
const result = findInstalledMatch(atlassianTemplate, [
const result = findInstalledMatch(getDefaultMcpTransport(linearEntry)!, [
{
id: "sse-0",
type: "sse",
url: "https://mcp.atlassian.com/v1/sse/",
url: "https://mcp.linear.app/sse/",
},
]);
expect(result).toEqual(expect.objectContaining({ id: "sse-0" }));
});
it("returns null when servers carry malformed urls (defensive)", () => {
const result = findInstalledMatch(atlassianTemplate, [
const result = findInstalledMatch(getDefaultMcpTransport(linearEntry)!, [
// Cast to any to simulate runtime data slipping past the type.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ id: "sse-0", type: "sse", url: undefined as any },
@@ -92,10 +90,50 @@ describe("findInstalledMatch", () => {
});
});
describe("getInstallableMcpConnectionOption", () => {
it("prefers Slack's API fallback over the default OAuth option", () => {
const option = getInstallableMcpConnectionOption(slackEntry);
expect(option?.id).toBe("api");
expect(option?.auth.strategy).toBe("api_key");
expect(option?.transport.kind).toBe("stdio");
});
it("returns undefined for an OAuth-only entry (no locally installable option)", () => {
const oauthOnlyEntry: Parameters<typeof getInstallableMcpConnectionOption>[0] =
{
...slackEntry,
id: "oauth-only",
defaultConnectionOptionId: "oauth",
connectionOptions: [
{
id: "oauth",
provider: "mcp",
auth: { strategy: "oauth2" },
transport: { kind: "shttp", url: "https://example.com/mcp" },
} as Parameters<typeof getInstallableMcpConnectionOption>[0]["connectionOptions"][number],
],
};
const option = getInstallableMcpConnectionOption(oauthOnlyEntry);
expect(option).toBeUndefined();
});
it("returns undefined when the entry has no MCP connection options", () => {
const noOptionsEntry: Parameters<typeof getInstallableMcpConnectionOption>[0] =
{
...slackEntry,
id: "no-mcp",
defaultConnectionOptionId: undefined,
connectionOptions: [],
};
const option = getInstallableMcpConnectionOption(noOptionsEntry);
expect(option).toBeUndefined();
});
});
describe("isMarketplaceEntryAvailable", () => {
it("treats unset availability as 'all'", () => {
expect(isMarketplaceEntryAvailable(tavilyEntry, "local")).toBe(true);
expect(isMarketplaceEntryAvailable(tavilyEntry, "cloud")).toBe(true);
expect(isMarketplaceEntryAvailable(slackEntry, "local")).toBe(true);
expect(isMarketplaceEntryAvailable(slackEntry, "cloud")).toBe(true);
});
it("hides local-only entries on cloud", () => {
@@ -106,12 +144,12 @@ describe("isMarketplaceEntryAvailable", () => {
describe("marketplaceEntryMatchesQuery", () => {
it("matches by name (case-insensitive)", () => {
expect(marketplaceEntryMatchesQuery(tavilyEntry, "tavily")).toBe(true);
expect(marketplaceEntryMatchesQuery(tavilyEntry, "TAVILY")).toBe(true);
expect(marketplaceEntryMatchesQuery(slackEntry, "slack")).toBe(true);
expect(marketplaceEntryMatchesQuery(slackEntry, "SLACK")).toBe(true);
});
it("matches by keyword", () => {
expect(marketplaceEntryMatchesQuery(tavilyEntry, "search")).toBe(true);
expect(marketplaceEntryMatchesQuery(slackEntry, "messaging")).toBe(true);
});
it("matches by substring of description", () => {
@@ -119,35 +157,35 @@ describe("marketplaceEntryMatchesQuery", () => {
});
it("returns true for empty/whitespace queries", () => {
expect(marketplaceEntryMatchesQuery(tavilyEntry, "")).toBe(true);
expect(marketplaceEntryMatchesQuery(tavilyEntry, " ")).toBe(true);
expect(marketplaceEntryMatchesQuery(slackEntry, "")).toBe(true);
expect(marketplaceEntryMatchesQuery(slackEntry, " ")).toBe(true);
});
it("returns false for non-matches", () => {
expect(marketplaceEntryMatchesQuery(tavilyEntry, "zzzz-no-match")).toBe(
expect(marketplaceEntryMatchesQuery(slackEntry, "zzzz-no-match")).toBe(
false,
);
});
});
describe("installedServerMatchesQuery", () => {
const tavilyServer = {
const slackServer = {
id: "stdio-0",
type: "stdio" as const,
name: "tavily",
name: "slack",
command: "npx",
args: ["-y", "tavily-mcp"],
args: ["-y", "@zencoderai/slack-mcp-server"],
};
it("matches by stdio server name", () => {
expect(installedServerMatchesQuery(tavilyServer, undefined, "tavily")).toBe(
expect(installedServerMatchesQuery(slackServer, undefined, "slack")).toBe(
true,
);
});
it("matches via the catalog entry's name even if server.name differs", () => {
const renamed = { ...tavilyServer, name: "my-tavily-instance" };
expect(installedServerMatchesQuery(renamed, tavilyEntry, "tavily")).toBe(
const renamed = { ...slackServer, name: "my-slack-instance" };
expect(installedServerMatchesQuery(renamed, slackEntry, "slack")).toBe(
true,
);
});
@@ -156,31 +194,31 @@ describe("installedServerMatchesQuery", () => {
const sseServer = {
id: "sse-0",
type: "sse" as const,
url: "https://mcp.atlassian.com/v1/sse",
url: "https://mcp.linear.app/sse",
};
expect(installedServerMatchesQuery(sseServer, undefined, "atlassian")).toBe(
expect(installedServerMatchesQuery(sseServer, undefined, "linear")).toBe(
true,
);
});
it("empty query always matches", () => {
expect(installedServerMatchesQuery(tavilyServer, undefined, "")).toBe(true);
expect(installedServerMatchesQuery(slackServer, undefined, "")).toBe(true);
});
});
describe("findCatalogEntryForServer", () => {
it("finds the Tavily catalog entry for an installed Tavily stdio server", () => {
it("finds the Slack catalog entry for an installed Slack stdio server", () => {
const match = findCatalogEntryForServer(
{
id: "stdio-0",
type: "stdio",
name: "tavily",
name: "slack",
command: "npx",
args: [],
},
INTEGRATION_MARKETPLACE,
mcpMarketplace,
);
expect(match?.id).toBe("tavily");
expect(match?.id).toBe("slack");
});
it("returns undefined for unknown servers", () => {
@@ -193,7 +231,7 @@ describe("findCatalogEntryForServer", () => {
command: "npx",
args: [],
},
INTEGRATION_MARKETPLACE,
mcpMarketplace,
),
).toBeUndefined();
});
@@ -203,15 +241,16 @@ describe("findCatalogEntryForServer", () => {
// diverged from findInstalledMatch and caused installed cards to
// render the generic icon while the marketplace tile said
// "Installed".
// Atlassian has SSE as its default MCP transport
if (atlassianTemplate?.kind !== "sse") {
throw new Error("Atlassian template should be SSE");
const linear = mcpMarketplace.find((e) => e.id === "linear")!;
const linearTransport = getDefaultMcpTransport(linear);
if (linearTransport?.kind !== "sse") {
throw new Error("Linear template should be SSE");
}
const normalizedUrl = atlassianTemplate.url.replace(/\/$/, "");
const normalizedUrl = linearTransport.url.replace(/\/$/, "");
const match = findCatalogEntryForServer(
{ id: "sse-0", type: "sse", url: `${normalizedUrl}/` },
INTEGRATION_MARKETPLACE,
mcpMarketplace,
);
expect(match?.id).toBe("atlassian");
expect(match?.id).toBe("linear");
});
});
+1 -1
View File
@@ -3441,7 +3441,7 @@
},
"node_modules/@openhands/extensions": {
"version": "0.0.0",
"resolved": "git+ssh://git@github.com/OpenHands/extensions.git#e14f740c59b4bfd7369d4bb6aea5eeb33dd05909",
"resolved": "git+https://github.com/OpenHands/extensions.git#e14f740c59b4bfd7369d4bb6aea5eeb33dd05909",
"integrity": "sha512-PC0pmJD1AiNP9aDfdDfDPP1iF40m/QN3vyv/xPN9sigLBtCmNBugSIySWEGVIGydARYkVi+NHL2KorPtlAOFAg==",
"license": "MIT",
"engines": {
@@ -13,13 +13,13 @@ import type { RecommendedAutomation } from "@openhands/extensions/automations";
import { parseMcpConfig } from "#/utils/mcp-config";
import { flattenMcpConfig } from "#/utils/mcp-installed-servers";
import {
INTEGRATION_CATALOG as INTEGRATION_MARKETPLACE,
INTEGRATION_CATALOG as MCP_MARKETPLACE,
type IntegrationCatalogEntry as MarketplaceEntry,
} from "@openhands/extensions/integrations";
import {
findInstalledMatch,
getInstallableTemplate,
findInstalledEntryMatch,
getMarketplaceEntryById,
getMcpMarketplaceCatalog,
} from "#/utils/mcp-marketplace-utils";
import { InstallServerModal } from "#/components/features/mcp-page/install-server-modal";
import { RecommendedAutomationsSection } from "./recommended-automations-section";
@@ -30,8 +30,9 @@ interface RecommendedAutomationsLauncherProps {
}
function getRequiredEntries(automation: RecommendedAutomation) {
const mcpMarketplace = getMcpMarketplaceCatalog(MCP_MARKETPLACE);
return automation.requiredIntegrationIds
.map((id) => getMarketplaceEntryById(id, INTEGRATION_MARKETPLACE))
.map((id) => getMarketplaceEntryById(id, mcpMarketplace))
.filter((entry): entry is MarketplaceEntry => !!entry);
}
@@ -154,10 +155,9 @@ export function RecommendedAutomationsLauncher({
const getMissingEntries = useCallback(
(automation: RecommendedAutomation) =>
getRequiredEntries(automation).filter((entry) => {
const template = getInstallableTemplate(entry);
return !template || !findInstalledMatch(template, installedMcpServers);
}),
getRequiredEntries(automation).filter(
(entry) => !findInstalledEntryMatch(entry, installedMcpServers),
),
[installedMcpServers],
);
@@ -6,7 +6,7 @@ import {
type RecommendedAutomation,
} from "@openhands/extensions/automations";
import {
INTEGRATION_CATALOG as INTEGRATION_MARKETPLACE,
INTEGRATION_CATALOG as MCP_MARKETPLACE,
type IntegrationCatalogEntry as MarketplaceEntry,
} from "@openhands/extensions/integrations";
import { McpLogoStackBadge } from "#/components/features/mcp-page/mcp-logo-stack-badge";
@@ -18,9 +18,9 @@ import {
import { CirclePlusBadge } from "#/components/shared/buttons/circle-plus-check-toggle";
import { MCPServerConfig } from "#/types/mcp-server";
import {
findInstalledMatch,
getInstallableTemplate,
findInstalledEntryMatch,
getMarketplaceEntryById,
getMcpMarketplaceCatalog,
isMarketplaceEntryAvailable,
} from "#/utils/mcp-marketplace-utils";
import { cn } from "#/utils/utils";
@@ -57,8 +57,9 @@ export function getAutomationsByPopularity(
const RECOMMENDED_AUTOMATIONS = getAutomationsByPopularity(AUTOMATION_CATALOG);
function getRequiredEntries(automation: RecommendedAutomation) {
const mcpMarketplace = getMcpMarketplaceCatalog(MCP_MARKETPLACE);
return automation.requiredIntegrationIds
.map((id) => getMarketplaceEntryById(id, INTEGRATION_MARKETPLACE))
.map((id) => getMarketplaceEntryById(id, mcpMarketplace))
.filter((entry): entry is MarketplaceEntry => !!entry);
}
@@ -99,10 +100,7 @@ function buildRecommendedAutomationPills(
translate: TFunction,
): SkillCardPill[] {
const pills: SkillCardPill[] = requiredEntries.map((entry) => {
const template = getInstallableTemplate(entry);
const installed = template
? !!findInstalledMatch(template, installedServers)
: false;
const installed = !!findInstalledEntryMatch(entry, installedServers);
return {
id: `mcp-${entry.id}`,
@@ -182,12 +180,9 @@ export function RecommendedAutomationsSection({
<div className={extensionModuleCardGridClassName}>
{visibleAutomations.map((automation) => {
const requiredEntries = getRequiredEntries(automation);
const missingCount = requiredEntries.filter((entry) => {
const template = getInstallableTemplate(entry);
return (
!template || !findInstalledMatch(template, installedServers)
);
}).length;
const missingCount = requiredEntries.filter(
(entry) => !findInstalledEntryMatch(entry, installedServers),
).length;
return (
<button
@@ -1,6 +1,7 @@
import React, { useId } from "react";
import React from "react";
import { useTranslation } from "react-i18next";
import { AxiosError } from "axios";
import { v4 as uuidv4 } from "uuid";
import type { MCPTestFailure } from "@openhands/typescript-client";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { ModalCloseButton } from "#/components/shared/modals/modal-close-button";
@@ -13,8 +14,11 @@ 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 {
getInstallableMcpConnectionOption,
type McpMarketplaceConnectionOption,
} from "#/utils/mcp-marketplace-utils";
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
import { getInstallableTemplate } from "#/utils/mcp-marketplace-utils";
interface InstallServerModalProps {
entry: MarketplaceEntry;
@@ -27,18 +31,34 @@ interface FieldState {
errors: Record<string, string | null>;
}
function optionNeedsCredentialField(
option: McpMarketplaceConnectionOption | undefined,
): boolean {
if (option?.transport.kind !== "shttp" && option?.transport.kind !== "sse") {
return false;
}
return ["api_key", "bearer", "basic"].includes(option.auth.strategy);
}
function isCredentialOptional(option: McpMarketplaceConnectionOption): boolean {
if (option.transport.kind === "stdio") {
return option.auth.apiKeyOptional ?? false;
}
return option.auth.apiKeyOptional ?? option.transport.apiKeyOptional ?? false;
}
function makeInitialState(entry: MarketplaceEntry): FieldState {
const values: Record<string, string> = {};
const template = getInstallableTemplate(entry);
if (!template) return { values, errors: {} };
if (template.kind === "stdio") {
const option = getInstallableMcpConnectionOption(entry);
const template = option?.transport;
if (template?.kind === "stdio") {
for (const field of template.envFields ?? []) {
values[field.key] = "";
}
for (const field of template.argFields ?? []) {
values[field.key] = "";
}
} else if (template.kind === "shttp" || template.kind === "sse") {
} else if (optionNeedsCredentialField(option)) {
values.api_key = "";
}
return { values, errors: {} };
@@ -58,12 +78,13 @@ export function InstallServerModal({
const { t } = useTranslation("openhands");
const { mutate: addMcpServer, isPending: isAdding } = useAddMcpServer();
const { mutate: testMcpServer, isPending: isTesting } = useTestMcpServer();
const instanceId = useId();
const [state, setState] = React.useState<FieldState>(() =>
makeInitialState(entry),
);
const [globalError, setGlobalError] = React.useState<string | null>(null);
const option = getInstallableMcpConnectionOption(entry);
const template = option?.transport;
const isPending = isTesting || isAdding;
@@ -113,8 +134,6 @@ 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
@@ -123,11 +142,13 @@ export function InstallServerModal({
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")) {
if (template?.kind !== "shttp" && template?.kind !== "sse") {
return;
}
if (!option) return;
const apiKey = state.values.api_key?.trim() ?? "";
if (!template.apiKeyOptional && !apiKey) {
const needsCredential = optionNeedsCredentialField(option);
if (needsCredential && !isCredentialOptional(option) && !apiKey) {
setState((prev) => ({
...prev,
errors: { api_key: t(I18nKey.MCP$ERROR_FIELD_REQUIRED) },
@@ -135,10 +156,10 @@ export function InstallServerModal({
return;
}
const payload: MCPServerConfig = {
id: `${template.kind}-${instanceId}`,
id: `${template.kind}-${uuidv4()}`,
type: template.kind,
url: template.url,
...(apiKey && { api_key: apiKey }),
...(needsCredential && apiKey && { api_key: apiKey }),
};
submitServer(payload);
};
@@ -180,7 +201,7 @@ export function InstallServerModal({
}
const payload: MCPServerConfig = {
id: `stdio-${instanceId}`,
id: `stdio-${uuidv4()}`,
type: "stdio",
name: stdio.serverName,
command: stdio.command,
@@ -200,9 +221,9 @@ export function InstallServerModal({
};
const renderFields = () => {
if (!template) return null;
if (template.kind === "shttp" || template.kind === "sse") {
const apiKeyOptional = template.apiKeyOptional ?? false;
if (template?.kind === "shttp" || template?.kind === "sse") {
const shouldRenderCredential = optionNeedsCredentialField(option);
const apiKeyOptional = option ? isCredentialOptional(option) : false;
return (
<>
<SettingsInput
@@ -215,27 +236,30 @@ export function InstallServerModal({
isDisabled
className="w-full"
/>
<div className="flex flex-col gap-1">
<SettingsInput
testId="mcp-install-field-api_key"
name="api_key"
type="password"
label={t(I18nKey.SETTINGS$MCP_API_KEY)}
value={state.values.api_key ?? ""}
onChange={(v) => setValue("api_key", v)}
placeholder={t(I18nKey.SETTINGS$MCP_API_KEY_PLACEHOLDER)}
showOptionalTag={apiKeyOptional}
required={!apiKeyOptional}
className="w-full"
/>
{state.errors.api_key && (
<p className="text-xs text-red-500">{state.errors.api_key}</p>
)}
</div>
{shouldRenderCredential ? (
<div className="flex flex-col gap-1">
<SettingsInput
testId="mcp-install-field-api_key"
name="api_key"
type="password"
label={t(I18nKey.SETTINGS$MCP_API_KEY)}
value={state.values.api_key ?? ""}
onChange={(v) => setValue("api_key", v)}
placeholder={t(I18nKey.SETTINGS$MCP_API_KEY_PLACEHOLDER)}
showOptionalTag={apiKeyOptional}
required={!apiKeyOptional}
className="w-full"
/>
{state.errors.api_key && (
<p className="text-xs text-red-500">{state.errors.api_key}</p>
)}
</div>
) : null}
</>
);
}
if (template?.kind !== "stdio") return null;
const stdio = template;
return (
<>
@@ -5,8 +5,11 @@ import { I18nKey } from "#/i18n/declaration";
import { McpLogoBadge } from "#/components/features/mcp-logo-badge";
import { CirclePlusCheckToggle } from "#/components/shared/buttons/circle-plus-check-toggle";
import { MCPServerConfig } from "#/types/mcp-server";
import { INTEGRATION_CATALOG as INTEGRATION_MARKETPLACE } from "@openhands/extensions/integrations";
import { findCatalogEntryForServer } from "#/utils/mcp-marketplace-utils";
import { INTEGRATION_CATALOG as MCP_MARKETPLACE } from "@openhands/extensions/integrations";
import {
findCatalogEntryForServer,
getMcpMarketplaceCatalog,
} from "#/utils/mcp-marketplace-utils";
import { cn } from "#/utils/utils";
import {
extensionModuleCardInteractiveClassName,
@@ -52,7 +55,10 @@ export function InstalledServerCard({
onDelete,
}: InstalledServerCardProps) {
const { t } = useTranslation("openhands");
const catalog = findCatalogEntryForServer(server, INTEGRATION_MARKETPLACE);
const catalog = findCatalogEntryForServer(
server,
getMcpMarketplaceCatalog(MCP_MARKETPLACE),
);
const title = catalog?.name ?? getServerTitle(server);
const detailLine = getServerDetailLine(server);
@@ -1,8 +1,8 @@
import { I18nKey } from "#/i18n/declaration";
import type { IntegrationCatalogEntry as MarketplaceEntry } from "@openhands/extensions/integrations";
import { McpLogoBadge } from "#/components/features/mcp-logo-badge";
import { getDefaultTemplate } from "#/utils/mcp-marketplace-utils";
import { CirclePlusCheckToggle } from "#/components/shared/buttons/circle-plus-check-toggle";
import { getDefaultMcpTransport } from "#/utils/mcp-marketplace-utils";
import { cn } from "#/utils/utils";
import {
extensionModuleCardInteractiveClassName,
@@ -20,10 +20,9 @@ export function MarketplaceCard({
onClick,
onAdd,
}: MarketplaceCardProps) {
const template = getDefaultTemplate(entry);
const transport = getDefaultMcpTransport(entry);
const transportLabel = (() => {
if (!template) return "";
switch (template.kind) {
switch (transport?.kind) {
case "stdio":
return "STDIO";
case "shttp":
@@ -1,11 +1,12 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import {
INTEGRATION_CATALOG as INTEGRATION_MARKETPLACE,
INTEGRATION_CATALOG as MCP_MARKETPLACE,
type IntegrationCatalogEntry as MarketplaceEntry,
} from "@openhands/extensions/integrations";
import {
getMarketplaceEntriesByPopularity,
getMcpMarketplaceCatalog,
isMarketplaceEntryAvailable,
marketplaceEntryMatchesQuery,
} from "#/utils/mcp-marketplace-utils";
@@ -32,7 +33,7 @@ export function MarketplaceSection({
const { t } = useTranslation("openhands");
const visibleEntries = getMarketplaceEntriesByPopularity(
INTEGRATION_MARKETPLACE,
getMcpMarketplaceCatalog(MCP_MARKETPLACE),
).filter(
(entry) =>
isMarketplaceEntryAvailable(entry, backendKind) &&
+5
View File
@@ -883,6 +883,11 @@ export const SETTINGS_HANDLERS = [
});
}),
http.post("*/api/mcp/test", async () => {
await delay();
return HttpResponse.json({ ok: true, tools: ["mock_tool"] });
}),
http.get("*/api/settings/agent-schema", async () => {
await delay();
return HttpResponse.json(MOCK_AGENT_SETTINGS_SCHEMA);
+4 -2
View File
@@ -18,10 +18,11 @@ import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message"
import { settingsLikeMainScrollClassName } from "#/utils/settings-like-page-layout-classes";
import {
findCatalogEntryForServer,
getMcpMarketplaceCatalog,
installedServerMatchesQuery,
} from "#/utils/mcp-marketplace-utils";
import {
INTEGRATION_CATALOG as INTEGRATION_MARKETPLACE,
INTEGRATION_CATALOG as MCP_MARKETPLACE,
type IntegrationCatalogEntry as MarketplaceEntry,
} from "@openhands/extensions/integrations";
import { MCPServerConfig } from "#/types/mcp-server";
@@ -66,6 +67,7 @@ export default function MCPPage() {
const mcpConfig = parseMcpConfig(settings?.agent_settings?.mcp_config);
const allServers = flattenMcpConfig(mcpConfig);
const mcpMarketplace = getMcpMarketplaceCatalog(MCP_MARKETPLACE);
// Filter installed servers by the search query. We pair each server
// with its catalog entry (if any) so the search can match friendly
@@ -74,7 +76,7 @@ export default function MCPPage() {
const filteredInstalledServers = allServers.filter((server) =>
installedServerMatchesQuery(
server,
findCatalogEntryForServer(server, INTEGRATION_MARKETPLACE),
findCatalogEntryForServer(server, mcpMarketplace),
searchQuery,
),
);
+96 -83
View File
@@ -1,9 +1,70 @@
import { MCPServerConfig } from "#/types/mcp-server";
import type {
IntegrationCatalogEntry as MarketplaceEntry,
IntegrationTransport as MarketplaceTemplate,
IntegrationConnectionOption,
IntegrationTransport,
} from "@openhands/extensions/integrations";
export type { MarketplaceEntry };
export type McpMarketplaceConnectionOption = IntegrationConnectionOption & {
provider: "mcp";
transport: IntegrationTransport;
};
export function getMcpConnectionOptions(
entry: MarketplaceEntry,
): McpMarketplaceConnectionOption[] {
return entry.connectionOptions.filter(
(option): option is McpMarketplaceConnectionOption =>
option.provider === "mcp" && !!option.transport,
);
}
export function getDefaultMcpConnectionOption(
entry: MarketplaceEntry,
): McpMarketplaceConnectionOption | undefined {
const options = getMcpConnectionOptions(entry);
return (
options.find((option) => option.id === entry.defaultConnectionOptionId) ??
options[0]
);
}
function isLocallyInstallableMcpOption(
option: McpMarketplaceConnectionOption,
): boolean {
// The local install modal writes static MCP server config. OAuth options
// describe hosted redirect flows, so prefer an API/stdio fallback when one
// exists and leave OAuth as the default connection for hosted integrations.
return option.auth.strategy !== "oauth2";
}
export function getInstallableMcpConnectionOption(
entry: MarketplaceEntry,
): McpMarketplaceConnectionOption | undefined {
const options = getMcpConnectionOptions(entry);
const defaultOption = options.find(
(option) => option.id === entry.defaultConnectionOptionId,
);
if (defaultOption && isLocallyInstallableMcpOption(defaultOption)) {
return defaultOption;
}
return options.find(isLocallyInstallableMcpOption);
}
export function getDefaultMcpTransport(
entry: MarketplaceEntry,
): IntegrationTransport | undefined {
return getDefaultMcpConnectionOption(entry)?.transport;
}
export function getMcpMarketplaceCatalog(
catalog: MarketplaceEntry[],
): MarketplaceEntry[] {
return catalog.filter((entry) => !!getDefaultMcpConnectionOption(entry));
}
const tryUrl = (raw: string): URL | null => {
try {
return new URL(raw);
@@ -39,78 +100,51 @@ export function urlsMatch(a: unknown, b: unknown): boolean {
);
}
/**
* Get the default transport template from an integration catalog entry.
* Integrations may have multiple connection options; we use the default
* one (or the first if no default is specified). Only MCP-backed options
* have a `transport` field.
*/
export function getDefaultTemplate(
entry: MarketplaceEntry,
): MarketplaceTemplate | undefined {
const option =
entry.connectionOptions.find(
(o) => o.id === entry.defaultConnectionOptionId,
) ?? entry.connectionOptions[0];
return option?.transport;
}
/**
* 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.
*/
export function getInstallableTemplate(
entry: MarketplaceEntry,
): MarketplaceTemplate | undefined {
// First, try to find a stdio option (API key-based, what we can actually install)
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);
}
/**
* Decide whether a marketplace template is already represented by one
* of the installed MCP servers. Used to render an "Installed" badge on
* the marketplace tile. Returns the first matching server, or null.
*/
export function findInstalledMatch(
template: MarketplaceTemplate,
transport: IntegrationTransport,
servers: MCPServerConfig[],
): MCPServerConfig | null {
if (template.kind === "shttp") {
const tplUrl = template.url;
if (!tplUrl) return null;
return (
servers.find((server) => transportMatchesServer(transport, server)) ?? null
);
}
export function findInstalledEntryMatch(
entry: MarketplaceEntry,
servers: MCPServerConfig[],
): MCPServerConfig | null {
for (const option of getMcpConnectionOptions(entry)) {
const match = findInstalledMatch(option.transport, servers);
if (match) return match;
}
return null;
}
function transportMatchesServer(
transport: IntegrationTransport,
server: MCPServerConfig,
): boolean {
if (transport.kind === "shttp") {
const tplUrl = transport.url;
return (
servers.find(
(s) => s.type === "shttp" && !!s.url && urlsMatch(s.url, tplUrl),
) ?? null
server.type === "shttp" && !!server.url && urlsMatch(server.url, tplUrl)
);
}
if (template.kind === "sse") {
const tplUrl = template.url;
if (!tplUrl) return null;
if (transport.kind === "sse") {
const tplUrl = transport.url;
return (
servers.find(
(s) => s.type === "sse" && !!s.url && urlsMatch(s.url, tplUrl),
) ?? null
server.type === "sse" && !!server.url && urlsMatch(server.url, tplUrl)
);
}
// stdio: match on the registered server name.
return (
servers.find((s) => s.type === "stdio" && s.name === template.serverName) ??
null
);
return server.type === "stdio" && server.name === transport.serverName;
}
export function isMarketplaceEntryAvailable(
@@ -203,38 +237,17 @@ export function installedServerMatchesQuery(
* Look up the catalog entry that best matches an installed server.
* Mirrors the lookup used in `installed-server-card.tsx` for
* rendering the friendly icon.
*
* Since an entry may have multiple connection options (e.g., OAuth + stdio),
* we check ALL templates in the entry's connectionOptions, not just the default.
*/
export function findCatalogEntryForServer(
server: MCPServerConfig,
catalog: MarketplaceEntry[],
): MarketplaceEntry | undefined {
return catalog.find((entry) => {
// Check all connection options, not just the default
for (const option of entry.connectionOptions) {
const tpl = option.transport;
if (!tpl) continue;
if (tpl.kind === "stdio") {
if (server.type === "stdio" && server.name === tpl.serverName)
return true;
}
// Reuse the same loose URL match as `findInstalledMatch` so a
// server whose URL was normalized by the backend (trailing slash
// stripped, query string dropped, etc.) still gets paired with
// its catalog tile — otherwise the installed-servers list would
// 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))
return true;
}
if (tpl.kind === "sse") {
if (server.type === "sse" && urlsMatch(server.url, tpl.url))
return true;
}
}
return false;
// Check every MCP option rather than only the default. Some unified
// integration entries default to OAuth-hosted MCP while still exposing
// an API/stdio option; existing installed servers should match either.
return getMcpConnectionOptions(entry).some((option) =>
transportMatchesServer(option.transport, server),
);
});
}
+89 -22
View File
@@ -4,7 +4,7 @@ import { seedLocalStorage } from "./support/seed-local-storage";
/**
* Visual snapshot tests for the MCP page (/mcp).
*
* The MCP marketplace catalog is imported from @openhands/extensions/mcps,
* The MCP marketplace catalog is imported from @openhands/extensions/integrations,
* so it never requires an API call. Installed servers are read from
* settings.agent_settings.mcp_config (SDK format: { mcpServers: { ... } }).
*
@@ -28,13 +28,68 @@ async function dismissConsentModal(page: Page) {
/**
* Wire up the base routes every MCP page test needs.
*
* NOTE: Settings requests go to the same-origin Vite dev server where MSW
* wins over page.route(). We dismiss the consent modal after navigation
* instead of trying to suppress it here.
* Settings are owned here rather than by the real local backend. MCP saves do a
* pre-clear PATCH followed by the new config write, so the snapshot flow needs
* a deterministic in-memory settings response.
*/
async function setupMocks(page: Page) {
await seedLocalStorage(page);
let agentSettings: Record<string, unknown> = {};
let conversationSettings: Record<string, unknown> = {};
await page.route("**/api/settings", async (route) => {
const request = route.request();
if (request.method() === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
agent_settings: agentSettings,
conversation_settings: conversationSettings,
llm_api_key_is_set: true,
}),
});
return;
}
if (request.method() === "PATCH") {
const body = request.postDataJSON() as {
agent_settings_diff?: Record<string, unknown>;
conversation_settings_diff?: Record<string, unknown>;
};
agentSettings = {
...agentSettings,
...(body.agent_settings_diff ?? {}),
};
conversationSettings = {
...conversationSettings,
...(body.conversation_settings_diff ?? {}),
};
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
agent_settings: agentSettings,
conversation_settings: conversationSettings,
llm_api_key_is_set: true,
}),
});
return;
}
await route.fallback();
});
await page.route("**/api/mcp/test", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ ok: true, tools: ["mock_tool"] }),
});
});
await page.route("**/api/conversations/search**", async (route) => {
await route.fulfill({
status: 200,
@@ -141,13 +196,16 @@ test.describe("MCP Page Visual Snapshots", () => {
await expect(mcpPage).toBeVisible({ timeout: 15_000 });
await test.step("step 1 – marketplace before install", async () => {
await expect(
page.getByTestId("mcp-marketplace-card-slack"),
).toBeVisible({ timeout: 5_000 });
await expect(mcpPage).toHaveScreenshot("mcp-slack-install-1-marketplace.png", {
animations: "disabled",
maxDiffPixelRatio: 0.01,
await expect(page.getByTestId("mcp-marketplace-card-slack")).toBeVisible({
timeout: 5_000,
});
await expect(mcpPage).toHaveScreenshot(
"mcp-slack-install-1-marketplace.png",
{
animations: "disabled",
maxDiffPixelRatio: 0.01,
},
);
});
await test.step("step 2 – Slack install modal open", async () => {
@@ -155,10 +213,13 @@ test.describe("MCP Page Visual Snapshots", () => {
await expect(page.getByTestId("mcp-install-modal")).toBeVisible({
timeout: 5_000,
});
await expect(rootLayout).toHaveScreenshot("mcp-slack-install-2-modal.png", {
animations: "disabled",
maxDiffPixelRatio: 0.01,
});
await expect(rootLayout).toHaveScreenshot(
"mcp-slack-install-2-modal.png",
{
animations: "disabled",
maxDiffPixelRatio: 0.01,
},
);
});
await test.step("step 3 – fill in bot token and team ID", async () => {
@@ -168,10 +229,13 @@ test.describe("MCP Page Visual Snapshots", () => {
await page
.getByTestId("mcp-install-field-SLACK_TEAM_ID")
.fill("T01ABC123");
await expect(rootLayout).toHaveScreenshot("mcp-slack-install-3-filled.png", {
animations: "disabled",
maxDiffPixelRatio: 0.01,
});
await expect(rootLayout).toHaveScreenshot(
"mcp-slack-install-3-filled.png",
{
animations: "disabled",
maxDiffPixelRatio: 0.01,
},
);
});
await test.step("step 4 – submit and confirm Slack is installed", async () => {
@@ -190,10 +254,13 @@ test.describe("MCP Page Visual Snapshots", () => {
// Brief wait to let the toast and any animations settle
await page.waitForTimeout(400);
await expect(mcpPage).toHaveScreenshot("mcp-slack-install-4-installed.png", {
animations: "disabled",
maxDiffPixelRatio: 0.01,
});
await expect(mcpPage).toHaveScreenshot(
"mcp-slack-install-4-installed.png",
{
animations: "disabled",
maxDiffPixelRatio: 0.01,
},
);
});
});
@@ -161,7 +161,7 @@ test.describe("UI Visual Snapshots", () => {
await dismissConsentModal(page);
const homeScreen = page.getByTestId("home-screen");
await expect(homeScreen).toBeVisible({ timeout: 15000 });
await expect(homeScreen).toBeVisible({ timeout: 15_000 });
await page.waitForLoadState("networkidle");
const rootLayout = page.getByTestId("root-layout");